For this lab, I decided to explore time series data using GPS tracking information from my cat’s tracking collar. I turn the collar on whenever she goes outside, and I use the location services in the app to find out where she’s exploring. The data I used came from a trip she took on November 20, 2025, which I exported from the app as a KML file. After that, I converted the KML into a CSV and cleaned the columns so they could be used for analysis and plotting in R.
Once the data was ready, I built a 3D visualization where time is represented as the Z-axis. I also added a satellite image underneath the path so the movement could be viewed in context. I chose to make a 3D space-time cube because I think it’s a very intuitive and visually interesting way to look at movement over time. It also let me experiment with a “fourth dimension” by coloring each point based on my cat’s walking speed, which made the final plot both functional and fun to interpret.
# Install once if needed:
# install.packages(c("dplyr", "lubridate", "tidyr", "rgl", "readr"))
library(dplyr)
##
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
##
## filter, lag
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
library(lubridate)
##
## Attaching package: 'lubridate'
## The following objects are masked from 'package:base':
##
## date, intersect, setdiff, union
library(tidyr)
library(rgl)
library(readr)
# read the data
dat <- read_csv("tractive/mytrack.csv") # adjust path as needed
## Rows: 106 Columns: 4
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## dbl (3): X, Y, speed
## time (1): Time
##
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
# make sure speed is numeric
dat <- dat |> mutate(speed = as.numeric(speed))
dat <- dat %>%
mutate(
Time_clean = sub("^\\.", "", Time), # remove leading dot
time_hms = hms(Time_clean) # convert to Duration
)
as.numeric(dat$time_hms)
# Clean data and prepare time in seconds
dat_clean <- dat %>%
dplyr::filter(!is.na(X), !is.na(Y), !is.na(time_hms), !is.na(speed)) %>%
dplyr::mutate(time_sec = as.numeric(time_hms))
# Color points by speed (blue = slow, red = fast using heat.colors)
speed_vals <- dat_clean$speed
speed_cols <- heat.colors(length(speed_vals))[rank(speed_vals)]
# Set up axis ranges
x_range <- range(dat_clean$X, na.rm = TRUE)
y_range <- range(dat_clean$Y, na.rm = TRUE)
z_range <- range(dat_clean$time_sec, na.rm = TRUE)
# Compute z-axis ticks and labels as HH:MM from the numeric seconds
time_origin <- as.POSIXct("1970-01-01 00:00:00", tz = "UTC")
z_ticks <- pretty(z_range, n = 5)
z_ticks <- z_ticks[z_ticks >= z_range[1] & z_ticks <= z_range[2]]
z_labels <- format(time_origin + z_ticks, "%H:%M")
# Open a new 3D device
open3d()
## glX
## 1
# Empty 3D plot with correct limits
plot3d(
x = NA,
y = NA,
z = NA,
xlim = x_range,
ylim = y_range,
zlim = z_range,
xlab = "X",
ylab = "Y",
zlab = "Time (HH:MM)",
type = "n",
axes = FALSE
)
## Warning in min(x): no non-missing arguments to min; returning Inf
## Warning in max(x): no non-missing arguments to max; returning -Inf
## Warning in min(x): no non-missing arguments to min; returning Inf
## Warning in max(x): no non-missing arguments to max; returning -Inf
## Warning in min(x): no non-missing arguments to min; returning Inf
## Warning in max(x): no non-missing arguments to max; returning -Inf
# Add proper axes: X horizontal, Y horizontal depth, Z vertical
axes3d(edges = c("x--", "y--", "z--"))
# Add correct Z-axis with time labels
axis3d("z", at = z_ticks, labels = z_labels)
view3d(theta = 0, phi = 0, fov = 60, zoom = 0.9)
# --- Add an image to the XY plane ---
library(png)
img <- readPNG("tractive/map.png") # <-- change file name
img <- aperm(img, c(2,1,3))[, nrow(img):1, ]
# Z value where the image should be placed
z_img <- min(dat_clean$time_sec)
# Draw textured image on XY plane
rgl.surface(
x = matrix(c(x_range[1], x_range[2], x_range[1], x_range[2]), nrow = 2),
y = matrix(c(y_range[1], y_range[1], y_range[2], y_range[2]), nrow = 2),
z = matrix(z_img, nrow = 2, ncol = 2),
texture = img,
lit = FALSE
)
## Warning in rgl.surface(x = matrix(c(x_range[1], x_range[2], x_range[1], : 'rgl.surface' is deprecated.
## Use 'surface3d' instead.
## See help("Deprecated")
# Animate: add points and connecting line segments sequentially
n <- nrow(dat_clean)
for (i in seq_len(n)) {
# Draw point i, colored by speed
points3d(
x = dat_clean$X[i],
y = dat_clean$Y[i],
z = dat_clean$time_sec[i],
col = speed_cols[i],
size = 6
)
# Draw line segment from previous point to this one
if (i > 1) {
segments3d(
x = dat_clean$X[(i - 1):i],
y = dat_clean$Y[(i - 1):i],
z = dat_clean$time_sec[(i - 1):i],
col = "black",
lwd = 2
)
}
Sys.sleep(0.05) # adjust for faster/slower animation
}