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)
##   [1] 11318 11457 11602 11750 11902 12055 12214 12305 12309 12313 12317 12321
##  [13] 12325 12329 12333 12337 12341 12345 12349 12353 12357 12361 12365 12369
##  [25] 12373 12377 12381 12385 12389 12393 12397 12401 12405 12409 12413 12417
##  [37] 12421 12425 12429 12433 12437 12441 12445 12449 12453 12457 12461 12465
##  [49] 12469 12473 12477 12481 12485 12489 12493 12497 12501 12505 12509 12513
##  [61] 12517 12521 12525 12529 12533 12537 12541 12545 12549 12553 12557 12561
##  [73] 12565 12569 12573 12577 12581 12585 12589 12593 12597 12601 12605 12609
##  [85] 12613 12617 12621 12625 12629 12633 12641 12645 12649 12653 12657 12661
##  [97] 12665 12669 12673 12675 12853 12980 13140 13279 13429 13577
# 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
}