3D Bivariate Mapping: Combining NDVI and Climate Data

Vegetation across landscapes responds to climate conditions in complex patterns that vary dramatically from place to place. Single-variable maps often miss the interconnected nature of these responses. Bivariate analysis changes this by showing how vegetation productivity and moisture availability work together across space. This tutorial demonstrates how to build sophisticated 3D bivariate maps that merge satellite vegetation data with climate information, using Croatia’s varied landscape as our testing ground.
Why Bivariate Analysis Transforms Research
Traditional mapping approaches examine one variable at a time. Bivariate mapping breaks this limitation by displaying two variables simultaneously, exposing relationships that remain invisible otherwise. For vegetation research, pairing vegetation productivity (NDVI) with climate moisture availability (CMI) reveals essential patterns that single-variable maps cannot capture.
Adding 3D terrain visualization makes these patterns immediately interpretable for land managers and researchers.
Building the Data Foundation
Accessing Sentinel-2 NDVI Through Google Earth Engine
Google Earth Engine transforms satellite data access by providing computational power alongside massive data archives. Sentinel-2 satellites capture images at 10-meter resolution, while the NDVI measures vegetation greenness and productivity across all vegetation types.
NDVI calculates the ratio between near-infrared and red light reflectance: (NIR - Red) / (NIR + Red). Healthy vegetation strongly reflects near-infrared light while absorbing red light for photosynthesis, creating high NDVI values. Values range from -1 to +1, with water and bare soil showing negative values, sparse vegetation around 0.2-0.4, and dense healthy vegetation reaching 0.7-0.9.
# Load packages for bivariate analysis workflow
if (!require("pacman")) install.packages("pacman")
if (!require("remotes")) install.packages("remotes")
if (!require("rgee")) remotes::install_github("r-spatial/rgee")
if (!require("biscale")) remotes::install_github("chris-prener/biscale")
pacman::p_load(
rgee, # Google Earth Engine interface
geodata, # Administrative boundaries
tidyverse, # Data manipulation tools
sf, # Spatial vector data handling
terra, # Modern raster data processing
biscale, # Bivariate mapping functions
elevatr, # Elevation data access
cowplot, # Plot composition tools
rayshader, # 3D visualization engine
magick # Image composition and editing
)
# Set the working directory
main_dir <- getwd()
# Establish Google Earth Engine connection
ee_Initialize() # Requires prior authentication setup
# Define Croatia as study area using Earth Engine's country boundaries
croatia <- ee$FeatureCollection("USDOS/LSIB_SIMPLE/2017")$
filter(ee$Filter$eq("country_na", "Croatia"))
The NDVI calculation function shows how Earth Engine handles large-scale satellite data processing. Rather than downloading individual images, the function processes entire collections on Google’s servers, returning only the final composite.
# Function to calculate NDVI for any specified year
get_ndvi_for_year <- function(year) {
# Focus on summer growing season when vegetation signals are strongest
start <- paste0(year, "-06-01")
end <- paste0(year, "-08-31")
# Build filtered Sentinel-2 collection with quality controls
s2_collection <- ee$ImageCollection("COPERNICUS/S2_SR")$
filterDate(start, end)$ # Temporal filter
filterBounds(croatia)$ # Spatial filter
filter(ee$Filter$lt("CLOUDY_PIXEL_PERCENTAGE", 20)) # Cloud filter
# Calculate NDVI for each image: (NIR - Red) / (NIR + Red)
# Band 8 = Near Infrared, Band 4 = Red
ndvi_collection <- s2_collection$map(function(image) {
return(image$normalizedDifference(c('B8', 'B4')))
})
# Median compositing reduces cloud effects and atmospheric noise
ndvi_median <- ndvi_collection$median()
# Clip result to country boundary
return(ndvi_median$clip(croatia))
}
Multi-Year NDVI Time Series
Vegetation conditions change from year to year based on weather patterns, disturbances, and land management activities. Calculating NDVI across multiple years reveals these temporal dynamics and enables more robust long-term analysis.
# Calculate NDVI for six-year period to capture temporal variability
ndvi_2018 <- get_ndvi_for_year(2018)
ndvi_2019 <- get_ndvi_for_year(2019)
ndvi_2020 <- get_ndvi_for_year(2020)
ndvi_2021 <- get_ndvi_for_year(2021)
ndvi_2022 <- get_ndvi_for_year(2022)
ndvi_2023 <- get_ndvi_for_year(2023)
# Export function sends processed data to Google Drive for local analysis
export_to_drive <- function(image, year) {
task <- ee$batch$Export$image$toDrive(
image = image,
description = paste0("croatia_ndvi_", year),
folder = "Earth_Engine_Exports",
fileNamePrefix = paste0("croatia_ndvi_", year),
scale = 1000,
region = croatia$geometry()$bounds(),
maxPixels = 1e9 # Limit prevents timeout on large exports
)
task$start()
}
# Submit all export tasks to Earth Engine
years <- 2018:2023
lapply(years, function(year) export_to_drive(get(paste0("ndvi_", year)), year))
You can monitor the status of your export in two ways: Google Earth Engine Tasks tab (https://code.earthengine.google.com/tasks) or your Google Drive folder when complete (https://drive.google.com/drive/).
CHELSA Climate Data Integration
CHELSA provides high-resolution climate data that accounts for topographic effects on temperature and precipitation. The Climate Moisture Index (CMI) integrates precipitation with potential evapotranspiration, creating a comprehensive measure of water availability for vegetation growth.
# Create directory structure for climate data storage
dir.create("chelsa_cmi", showWarnings = FALSE)
# Base URL for CHELSA CMI climatological data (1981-2010 averages)
base_url <- "https://os.zhdk.cloud.switch.ch/chelsav2/GLOBAL/climatologies/1981-2010/cmi/"
# Download monthly CMI files to capture seasonal patterns
for(month in 1:12) {
month_str <- sprintf("%02d", month) # Format as two-digit string
filename <- paste0("CHELSA_cmi_", month_str, "_1981-2010_V.2.1.tif")
url <- paste0(base_url, filename)
local_path <- file.path("chelsa_cmi", filename)
# Skip download if file already exists locally
if(!file.exists(local_path)) {
download.file(url, local_path, mode = "wb", method = "auto")
Sys.sleep(2) # Brief pause to avoid overwhelming the server
}
}
# Process monthly files into annual average
cmi_files <- list.files("chelsa_cmi", pattern = "CHELSA_cmi_.*\\.tif$", full.names = TRUE)
cmi_stack <- terra::rast(cmi_files)
annual_cmi <- terra::app(cmi_stack, fun = "mean")
# Save processed annual CMI for later use
terra::writeRaster(annual_cmi, "chelsa_cmi/annual_cmi_1981-2010.tif", overwrite=TRUE)
Spatial Data Alignment and Preprocessing
Loading and Temporal Compositing
Combining multiple years of NDVI data requires careful alignment and compositing. This process reduces the influence of unusual weather years and creates a more representative vegetation baseline.
# Load all NDVI files from Google Earth Engine exports
ndvi_files <- paste0("croatia_ndvi_", 2018:2023, ".tif")
ndvi_list <- lapply(ndvi_files, rast)
# Align all rasters to same coordinate system and extent
# Using 2023 as reference ensures consistency
ndvi_aligned <- lapply(ndvi_list, function(r) {
terra::project(r, ndvi_list[[6]]) # Project to 2023 coordinate system
})
# Calculate multi-year average
ndvi_avg <- do.call(terra::mean, ndvi_aligned)
writeRaster(ndvi_avg, "croatia_ndvi_2018_2023_avg.tif", overwrite=TRUE)
# Load previously processed climate data
annual_cmi <- rast("chelsa_cmi/annual_cmi_1981-2010.tif")
Administrative Boundaries and Elevation Data
Accurate spatial context requires administrative boundaries and topographic information. These datasets provide essential reference frames for analysis and help interpret patterns in relation to geographic features.
# Download Croatia administrative boundaries from GADM database
croatia_sf <- geodata::gadm(
country = "HRV", # ISO country code for Croatia
level = 0,
path = main_dir
) |> sf::st_as_sf()
# Use WGS84 geographic coordinate system
target_crs <- "EPSG:4326"
# Obtain high-resolution elevation data for terrain context
dem_raw <- elevatr::get_elev_raster(
locations = croatia_sf,
z = 9,
clip = "locations"
) |>
terra::rast() |> # Convert to terra format
terra::crop(croatia_sf, mask = TRUE) |> # Crop and mask to country
terra::project(target_crs)
Achieving Spatial Alignment
Proper spatial alignment ensures that vegetation and climate data correspond to the same geographic locations. Misaligned data creates false patterns that can mislead analysis and interpretation.
# Crop climate and vegetation data to Croatia boundaries
cmi_croatia <- terra::crop(annual_cmi, croatia_sf, mask = TRUE)
ndvi_croatia <- terra::crop(ndvi_avg, croatia_sf, mask = TRUE)
# Resample all data to match DEM grid exactly
# This ensures identical extent, resolution, and cell alignment
ndvi_aligned <- terra::resample(
x = ndvi_croatia,
y = dem_raw,
method = "bilinear"
) |> terra::project(target_crs)
cmi_aligned <- terra::resample(
x = cmi_croatia,
y = dem_raw,
method = "bilinear"
) |> terra::project(target_crs)
# Combine datasets into single raster stack
ndvi_cmi_aligned <- c(ndvi_aligned, cmi_aligned)
names(ndvi_cmi_aligned) <- c("ndvi", "cmi")
Creating Bivariate Classifications and Maps
Fisher’s natural breaks classification, also known as Jenks optimization, represents one of the most sophisticated approaches to data classification in spatial analysis. This method addresses a fundamental challenge in bivariate mapping: how to divide continuous data into discrete classes while preserving the natural structure and relationships within your dataset.
The algorithm was originally developed by Fisher (1958) for statistical optimization problems, then adapted by Jenks (1967) specifically for cartographic applications. What makes Fisher’s method particularly powerful for bivariate analysis is its ability to minimize within-class variance while maximizing between-class variance, effectively identifying natural “breaks” or boundaries where data clusters separate most distinctly.
# Transform raster data into dataframe for bivariate classification
ndvi_cmi_df <- as.data.frame(
ndvi_cmi_aligned,
xy = TRUE, # Include spatial coordinates for mapping
na.rm = TRUE # Remove missing values from analysis
)
# Apply Fisher's natural breaks classification
breaks <- biscale::bi_class(
ndvi_cmi_df,
x = ndvi,
y = cmi,
style = "fisher",
dim = 3 # Create 3x3 grid for 9 total classes
)
Now, we’ll make functions for 2D and 3D rendering.
# Create theme functions for 2D and 3D
theme_base_bivariate <- function() {
theme_void() +
theme(
plot.background = element_rect(fill = "white", color = NA),
panel.background = element_rect(fill = "white", color = NA)
)
}
# Specialized theme additions for 2D mapping
theme_2d_additions <- function() {
theme(
plot.title = element_text(
size = 16, hjust = 0.5, face = "bold", margin = margin(b = 15)
),
plot.subtitle = element_text(
size = 12, hjust = 0.5, color = "grey40", margin = margin(b = 20)
),
plot.caption = element_text(
size = 9, hjust = 0.5, color = "grey50", margin = margin(t = 15)
),
plot.margin = margin(20, 20, 20, 20)
)
}
# Specialized theme additions for 3D rendering
theme_3d_additions <- function() {
theme(
legend.position = "none",
plot.margin = unit(c(0, 0, 0, 0), "lines")
)
}
# Core bivariate mapping function
create_bivariate_base <- function(data, include_labels = TRUE, theme_additions = NULL) {
base_map <- ggplot(data) +
geom_raster(aes(x = x, y = y, fill = bi_class)) +
biscale::bi_scale_fill(
pal = "GrPink",
dim = 3,
flip_axes = FALSE,
rotate_pal = FALSE
) +
coord_sf(crs = target_crs, expand = FALSE) +
theme_base_bivariate()
if (include_labels) {
base_map <- base_map +
labs(
title = "Vegetation Productivity and Climate Moisture Patterns",
subtitle = "NDVI × Climate Moisture Index (CMI)",
caption = "Data: Sentinel-2 via Google Earth Engine | CHELSA",
x = "", y = ""
)
}
# Apply additional theme elements when provided
if (!is.null(theme_additions)) {
base_map <- base_map + theme_additions
}
return(base_map)
}
# Create legend
bivariate_legend <- function(size = 9) {
biscale::bi_legend(
pal = "GrPink",
dim = 3,
xlab = "Higher NDVI",
ylab = "Higher CMI",
size = size
) +
theme(
panel.background = element_rect(fill = "white", color = "grey80", size = 1),
plot.background = element_rect(fill = "white", color = NA),
plot.margin = margin(10, 10, 10, 10)
)
}
2D Bivariate Mapping
With our functions established, creating the 2D bivariate map becomes straightforward and efficient.
bivariate_2d_map <- create_bivariate_base(
data = breaks,
include_labels = TRUE,
theme_additions = theme_2d_additions()
)
# Create legend for 2D display
bivariate_legend <- bivariate_legend(size = 9)
# Combine map and legend using cowplot
complete_2d_map <- cowplot::ggdraw() +
cowplot::draw_plot(
plot = bivariate_2d_map,
x = 0, y = 0, width = 1, height = 1
) +
cowplot::draw_plot(
plot = bivariate_legend,
x = 0.05, y = 0.15, width = 0.25, height = 0.25
)
# Display and save the 2D map
print(complete_2d_map)
ggsave(
filename = "croatia_bivariate_2d.png",
plot = complete_2d_map,
width = 10, height = 8, dpi = 300,
device = "png", bg = "white"
)

Figure 1: Vegetation-climate relationships across Croatia
Understanding the Nine Vegetation-Climate Classes
➡️ Class 1-1 (Light Blue): Low NDVI + Low CMI
These areas represent the combination of limited moisture and low vegetation productivity
➡️ Class 1-2 (Medium Blue): Low NDVI + Medium CMI
These areas have moderate moisture availability but still show limited vegetation response.
➡️ Class 1-3 (Dark Blue): Low NDVI + High CMI
Zones where abundant moisture exists but vegetation coverage remains low due to land cover type rather than climate limitations.
➡️ Class 2-1 (Light Pink): Medium NDVI + Low CMI
These regions demonstrate vegetation adaptation to water-limited conditions.
➡️ Class 2-2 (Medium Pink): Medium NDVI + Medium CMI
These zones show balanced relationships between moisture availability and vegetation response.
➡️ Class 2-3 (Dark Pink): Medium NDVI + High CMI
These regions have abundant moisture but vegetation response remains moderate, possibly due to topographic factors, soil conditions, or land management practices that limit maximum productivity.
➡️ Class 3-1 (Light Red): High NDVI + Low CMI
This represent highly efficient vegetation that maintains strong productivity despite limited water availability.
➡️ Class 3-2 (Medium Red): High NDVI + Medium CMI These regions indicate productive vegetation thriving under favorable but not exceptional moisture conditions.
➡️ Class 3-3 (Dark Red): High NDVI + High CMI
This represent optimal growing conditions where abundant moisture combines with high vegetation productivity.
3D Visualization and Terrain Integration
The transition from 2D to 3D visualization adds terrain context that reveals how topography influences vegetation-climate relationships.
main_map <- create_bivariate_base(
data = breaks,
include_labels = FALSE,
theme_additions = theme_3d_additions()
)
# Prepare elevation data for height mapping in 3D visualization
dem_df <- dem_raw |>
as.data.frame(xy = TRUE, na.rm = TRUE)
names(dem_df)[3] <- "elevation"
# Create neutral elevation map
dem_map <- ggplot(dem_df, aes(x = x, y = y, fill = elevation)) +
geom_raster() +
scale_fill_gradientn(colors = "white") +
guides(fill = "none") +
coord_sf(crs = target_crs, expand = FALSE) +
theme_void() +
theme(
plot.background = element_rect(fill = "white", color = NA),
panel.background = element_rect(fill = "white", color = NA),
legend.position = "none"
)
The rayshader package transforms our 2D map into stunning 3D visualization by treating the bivariate classification as surface texture and elevation data as terrain height.
options(rgl.useNULL = FALSE)
# Create initial 3D scene combining bivariate map with terrain
rayshader::plot_gg(
ggobj = main_map, # Bivariate classification as surface texture
ggobj_height = dem_map, # Terrain elevation as height information
width = 9, # Output dimensions in inches
height = 9,
windowsize = c(900, 900), # Display window size in pixels
scale = 250, # Vertical exaggeration for dramatic terrain
shadow = TRUE, # Enable shadows for depth perception
shadow_intensity = 1, # Shadow strength (0-1 scale)
phi = 90, # Camera elevation angle (0-90 degrees)
theta = 0, # Camera azimuth angle (0-360 degrees)
zoom = 0.56, # Camera zoom level
multicore = TRUE # Use multiple processor cores for speed
)
# Download high-quality lighting file for realistic rendering
hdri_url <- "https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/4k/brown_photostudio_02_4k.hdr"
hdri_file <- "lighting.hdr"
if (!file.exists(hdri_file)) {
try(download.file(
url = hdri_url,
destfile = hdri_file,
mode = "wb"
))
}
# Render final high-resolution image
rayshader::render_highquality(
filename = "croatia_3d_bivariate.png",
preview = TRUE, # Show preview during rendering
light = FALSE, # Use environmental lighting instead of point lights
environment_light = hdri_file,
intensity = 1.0, # Overall light intensity
rotate_env = 90, # Rotate lighting environment for optimal appearance
parallel = TRUE, # Enable parallel processing for speed
width = 2400, # Final image dimensions in pixels
height = 2400,
samples = 200, # Ray sampling rate (higher = better quality, slower)
interactive = FALSE # Non-interactive batch rendering
)
The final composition combines the 3D visualization with informative legends and titles.
# Create legend with larger text for 3D composition
legend_plot <- bivariate_legend(size = 13)
# Save legend as separate high-resolution image
ggsave("legend.png", plot = legend_plot, width = 4, height = 4, dpi = 300, bg = "white")
# Create title
title_plot <- ggplot() +
theme_void() +
theme(
plot.background = element_rect(fill = "white", color = NA),
plot.margin = margin(30, 30, 30, 30)
) +
annotate(
"text", x = 0.5, y = 0.7,
label = "VEGETATION PRODUCTIVITY & CLIMATE MOISTURE PATTERNS",
size = 14, fontface = "bold", color = "grey15", hjust = 0.5
) +
annotate(
"text", x = 0.5, y = 0.3,
label = "Bivariate Analysis • NDVI × Climate Moisture Index (CMI)",
size = 10, color = "grey35", hjust = 0.5
) +
xlim(0, 1) + ylim(0, 1)
# Save title as high-resolution image
ggsave("title.png", plot = title_plot, width = 20, height = 4, dpi = 300, bg = "white")
# Load all components for final assembly
main_img <- magick::image_read("croatia_3d_bivariate.png")
legend_img <- magick::image_read("legend.png")
title_img <- magick::image_read("title.png")
# Resize components for proper proportions in final layout
title_resized <- magick::image_resize(title_img, "2400x300!")
legend_resized <- magick::image_resize(legend_img, "500x500")
# Combine title with main image vertically
img_with_title <- magick::image_append(c(title_resized, main_img), stack = TRUE)
# Calculate precise legend positioning for optimal visual balance
legend_x <- 2400 - 880 # Position from left edge
legend_y <- (2700 - 400) / 2 + 200 # Center vertically with adjustment
# Create final composite image with all elements
final_composite <- magick::image_composite(
img_with_title,
legend_resized,
offset = paste0("+", legend_x, "+", legend_y),
operator = "over"
)
# Save final high-quality map
magick::image_write(final_composite, "croatia_bivariate_final_map.png", quality = 95)

Figure 2: 3D bivariate map showing vegetation-climate relationships across Croatia
References
- Biscale Package Documentation
- CHELSA Climate Data
- Fisher, W.D. (1958). On grouping for maximum homogeneity. Journal of the American Statistical Association, 53(284), 789-798
- Google Earth Engine Documentation
- Jenks, G.F. (1967). The data model concept in statistical mapping. International Yearbook of Cartography, 7, 186-190
- Popović, M. 3D Bivariate Climate Maps
- Rayshader GitHub Repository
- Rayshader Package
Need Advanced Forest Data Science?
I help research organizations transform their data workflows from manual to automated, providing comprehensive services from statistical analysis and modeling to spatial data integration and advanced visualizations. If you’re spending days on data collection and processing instead of focusing on analysis and insights, let’s talk about how custom R solutions can streamline your research pipeline.
My clients typically see:
- 90% reduction in data processing time
- Improved accuracy through automated workflows
- Rigorous statistical insights that strengthen research conclusions
- Reproducible analyses that scale across projects
📩Contact me: marijana@andalytics.com
Table of Contents
Similar Content

Introduction to Google Earth Engine: A Powerful Tool for Modern Forest Science
27 June, 2025
A comprehensive guide to using Google Earth Engine for forest research, with practical examples using R and the rgee package.
26 minutes read
Read More
Geographic Data for Forestry Research Made Easy: The R geodata Package
21 May, 2025
Transform geographic data acquisition using the geodata R package - access elevation, soil, climate, land cover and species occurrence datasets in minutes instead of days.
10 minutes read
Read More
Working with NetCDF Files in Forest Research: A Practical Guide
20 January, 2025
Learn how to work with NetCDF climate data files in forest research using R, including accessing CRU datasets, processing climate data, and implementing spatial interpolation techniques for forest plot analysis.
24 minutes read
Read More