Skip to contents
if (requireNamespace("pkgload", quietly = TRUE)) {
  # Always load from source so the vignette reflects the current development state.
  pkgload::load_all("..", export_all = FALSE, helpers = FALSE, quiet = TRUE)
} else if (requireNamespace("radiatR", quietly = TRUE)) {
  library(radiatR)
} else {
  stop("Package 'radiatR' not installed and 'pkgload' not available.")
}
library(ggplot2)

Overview

radiatR streamlines the journey from raw tracking files to publication-ready plots for experiments conducted in circular arenas. The package includes:

  • utilities for importing paired landmark/track text files produced by popular tracking suites;
  • helpers for extracting trial limits and computing circular summary statistics;
  • polished ggplot2 layers for drawing concentric guides and annotated paths.

Import Pipeline

The package ships the full set of millipede example tracks that demonstrate the import workflow. Landmark files (_point01.txt) record two pixel-space reference points per trial (arena centre and target location on the arena wall); track files (_point02.txt) contain the full xy trajectory.

track_dir     <- system.file("extdata", "tracks", package = "radiatR")
manifest_path <- system.file("extdata", "millipede_trials.csv", package = "radiatR")

file_tbl  <- import_tracks(track_dir)
manifest  <- import_info(manifest_path)
file_tbl  <- load_tracks(file_tbl, manifest, track_dir)
#> Warning in .augment_with_manifest(file_tbl, df, manifest_cols): Entries in
#> `file_tbl` with no matching metadata: con_19
#> Warning in .augment_with_manifest(file_tbl, df, manifest_cols): Rows in
#> `manifest` with no corresponding track: con_101, con_102, con_104, con_105,
#> con_108, con_109, con_110, con_112, con_116, con_117, con_119, con_120,
#> con_121, 5_101, 5_102, 5_103, 5_104, 5_108, 5_110, 5_117, 5_118, 5_119, 5_121,
#> 10_101, 10_102, 10_105, 10_107, 10_108, 10_109, 10_110, 10_111, 10_112, 10_113,
#> 10_114, 10_116, 10_117, 10_119, 10_121, 15_101, 15_102, 15_104, 15_105, 15_107,
#> 15_109, 15_110, 15_112, 15_116, 15_119, 15_120, 15_121, 20_101, 20_102, 20_103,
#> 20_104, 20_105, 20_106, 20_107, 20_108, 20_109, 20_110, 20_112, 20_116, 20_117,
#> 20_119, 20_121, 30_101, 30_102, 30_104, 30_105, 30_106, 30_107, 30_108, 30_109,
#> 30_113, 30_114, 30_115, 30_116, 30_117, 30_119, 30_121, 40_101, 40_102, 40_103,
#> 40_104, 40_105, 40_107, 40_108, 40_109, 40_110, 40_112, 40_118, 40_119, 40_121,
#> 50_12, 50_34, 50_101, 50_104, 50_105, 50_107, 50_108, 50_109, 50_110, 50_112,
#> 50_113, 50_119, 50_121

ts_demo <- suppressWarnings(get_all_object_pos(file_tbl = file_tbl, track_dir = track_dir))
ts_demo
#> TrajSet: 235 trajectories, 44331 observations
#> Columns: id='trial_id', time='frame', angle='rel_theta' (radians), x='trans_x', y='trans_y', rel_x='rel_x', rel_y='rel_y'
#> Transform steps: unit_circle_mapping 
#> # A tibble: 6 × 15
#>   trial_id frame     x     y trans_x  trans_y trans_rho abs_theta rel_theta
#>   <chr>    <int> <dbl> <dbl>   <dbl>    <dbl>     <dbl>     <dbl>     <dbl>
#> 1 10_1_1       1  456.  372. 0.00511  0         0.00511     0         6.21 
#> 2 10_1_1       2  458.  370. 0.0128   0.00770   0.0149      0.541     0.464
#> 3 10_1_1       3  456.  370. 0.00511  0.00770   0.00924     0.985     0.908
#> 4 10_1_1       4  454.  370. 0        0.00770   0.00770     1.57      1.49 
#> 5 10_1_1       5  456.  377. 0.00511 -0.0153    0.0162      5.03      4.96 
#> 6 10_1_1       6  459.  382. 0.0154  -0.0307    0.0343      5.18      5.10 
#> # ℹ 6 more variables: rel_x <dbl>, rel_y <dbl>, video <chr>, order <chr>,
#> #   vid_ord <chr>, radius <dbl>

get_all_object_pos() reads each landmark/track pair, normalises coordinates to a unit circle (arena radius = 1), and returns a TrajSet. Trial metadata (arena radius, target position, frame limits) is in ts_demo@meta$trial_limits.

Full Millipede Dataset

The package also provides cpunctatus, a pre-computed TrajSet of all 235 Cylindroiulus punctatus trajectories across the stimulus conditions (target half-widths of 5, 10, 15, 20, 30, 40, 50 degrees, plus a featureless control with arc = 0). Loading it is instant, and the per-trial target half-width is already attached as the arc column.

data(cpunctatus)
cpunctatus
#> TrajSet: 235 trajectories, 44331 observations
#> Columns: id='trial_id', time='frame', angle='rel_theta' (radians), x='trans_x', y='trans_y', rel_x='rel_x', rel_y='rel_y'
#> Transform steps: unit_circle_mapping 
#> # A tibble: 6 × 18
#>   trial_id frame     x     y trans_x  trans_y trans_rho abs_theta rel_theta
#>   <chr>    <int> <dbl> <dbl>   <dbl>    <dbl>     <dbl>     <dbl>     <dbl>
#> 1 10_1_1       1  456.  372. 0.00511  0         0.00511     0         6.21 
#> 2 10_1_1       2  458.  370. 0.0128   0.00770   0.0149      0.541     0.464
#> 3 10_1_1       3  456.  370. 0.00511  0.00770   0.00924     0.985     0.908
#> 4 10_1_1       4  454.  370. 0        0.00770   0.00770     1.57      1.49 
#> 5 10_1_1       5  456.  377. 0.00511 -0.0153    0.0162      5.03      4.96 
#> 6 10_1_1       6  459.  382. 0.0154  -0.0307    0.0343      5.18      5.10 
#> # ℹ 9 more variables: rel_x <dbl>, rel_y <dbl>, video <chr>, order <chr>,
#> #   vid_ord <chr>, radius <dbl>, arc <ord>, type <chr>, individual <chr>

Plotting Trajectories

radiate() draws trajectories on a unit circle with concentric reference rings. Colouring by the arc factor shows how paths cluster by condition.

radiate(cpunctatus,
        group_col  = "trial_id",
        colour_col = "arc",
        show_labels = FALSE,
        show_arrow  = FALSE,
        display    = circ_display(zero = 0))

Heading Overlays

The crossing method — projecting the vector between two concentric-ring crossings to the unit circle — assigns one directional heading per trial. derive_headings() with return_coords = TRUE returns both the heading angle and the inner-ring crossing position.

hd <- derive_headings(cpunctatus, rule = "crossing",
                      circ0 = 0.2, circ1 = 0.4,
                      return_coords = TRUE)
names(hd)[names(hd) == "id"] <- "trial_id"
# join the target half-width (arc) from the dataset for grouping/faceting
arc_map <- unique(cpunctatus@data[, c("trial_id", "arc")])
hd       <- merge(hd, arc_map, by = "trial_id")
hd$arc   <- factor(hd$arc)
attr(hd, "colour_col") <- "arc"
attr(hd, "display")    <- circ_display(zero = 0)
head(hd[, c("trial_id", "arc", "heading", "x_inner", "y_inner")])
#>   trial_id arc   heading     x_inner     y_inner
#> 1   10_1_1  10 0.3545811  0.19556350 -0.04102813
#> 2  10_10_1  10 6.2337825 -0.03715972  0.19558015
#> 3  10_11_1  10 1.3353505 -0.07231344  0.18624466
#> 4  10_12_1  10 2.2846471 -0.18291981  0.08084798
#> 5  10_13_1  10 5.6521348  0.18223038  0.08232129
#> 6  10_14_1  10 1.6589765  0.09025066  0.17836615

Overlaying the heading endpoints (one hollow circle per trial) and the grand mean direction on the combined trajectory plot gives a compact summary of the full dataset:

p_all <- radiate(cpunctatus,
                 group_col   = "trial_id",
                 colour_col  = "arc",
                 show_labels = FALSE,
                 show_arrow  = FALSE,
                 display     = circ_display(zero = 0)) +
  add_heading_points(hd, colour_col = "arc", size = 1, alpha = 0.6)

p_all + add_heading_arrow(hd)
#> Warning: Removed 25 rows containing missing values or values outside the scale range
#> (`geom_point()`).

The grand mean arrow points at 283.2° relative to the reference direction (clock convention; 0° = toward reference) with R = 0.08, reflecting the overall reference-relative tendency across all conditions.

Circular Interval Arc

Three functions handle directional uncertainty arcs in parallel with the density overlay functions:

Function Role
compute_circ_interval() Computes arc bounds from raw headings; returns a data frame with lower, upper, mean_dir, and wraps
add_circ_interval() Renders any bounds data frame as an arc at a configurable radius — agnostic to how the bounds were produced
add_heading_interval() Convenience wrapper: calls the two above in sequence

Two statistics are supported: stat = "bootstrap_ci" bootstraps the von Mises MLE confidence interval for the mean direction; stat = "sd" draws a ±1 circular SD arc. The split design lets you substitute Bayesian credible bounds into the data frame before rendering:

iv <- compute_circ_interval(hd, colour_col = "arc", stat = "bootstrap_ci")
# replace with Bayesian posteriors: iv$lower <- ...; iv$upper <- ...
add_circ_interval(iv, colour_col = "arc")

Building Up a Panel

Layers compose with the standard ggplot2 + operator, so a plot can be assembled feature by feature. The four chunks below start from trajectories only and add heading endpoints, the grand mean arrow, and finally a bootstrap CI arc.

Trajectories:

p <- radiate(cpunctatus,
             group_col   = "trial_id",
             colour_col  = "arc",
             show_labels = FALSE,
             show_arrow  = FALSE,
             display     = circ_display(zero = 0))
p

+ Heading endpoints at each trial’s crossing location:

p <- p + add_heading_points(hd, colour_col = "arc", size = 1.5, alpha = 0.7)
p
#> Warning: Removed 25 rows containing missing values or values outside the scale range
#> (`geom_point()`).

+ Grand mean direction arrow:

p <- p + add_heading_arrow(hd)
p
#> Warning: Removed 25 rows containing missing values or values outside the scale range
#> (`geom_point()`).

The arc at radius 1.05 spans the 95 % bootstrap confidence interval for the grand mean direction pooled across all arc conditions.

Colour Options

Three strategies control how trajectories are coloured.

Option 1 — single colour. Pass a colour string directly to the heading-overlay helpers; omit colour_col from radiate() to draw all tracks in the default grey.

Option 2 — cycling palette. colour_cycle assigns each trajectory a colour index that cycles back to 1 after every n trajectories. When panel_by is set the cycle restarts independently within each panel so that trajectory 1 in every panel always gets colour 1. Pass an integer for automatic palette assignment, or a character vector to specify colours explicitly.

Option 3 — variable mapping. colour_col maps any data column to colour (e.g. the arc factor used in the combined-trajectory plot above).

Per-Condition Panels

Faceting by arc angle shows each condition separately. Within each panel, assign_cycle_colours() distinguishes individual trajectories by cycling through 10 colours, resetting at each panel boundary. Calling it explicitly on both the track data and the headings data frame (joining by trial id) means heading markers inherit the exact per-trajectory colour — not a single per-condition colour — when colour_col is used for both.

# Pre-compute cycling colours: 10 colours, restarting within each arc panel.
# Join to headings so heading layers can use the same column.
cpunctatus_cc        <- cpunctatus
cpunctatus_cc@data   <- assign_cycle_colours(cpunctatus@data,
                                            id_col    = "trial_id",
                                            n         = 10,
                                            panel_col = "arc")
colour_map <- unique(cpunctatus_cc@data[, c("trial_id", "cycle_colour")])
hd_cc      <- merge(hd, colour_map,
                    by      = "trial_id", all.x = TRUE)
attr(hd_cc, "display")    <- attr(hd, "display", exact = TRUE)
attr(hd_cc, "colour_col") <- "cycle_colour"
p <- radiate(cpunctatus_cc,
        group_col    = "trial_id",
        colour_col   = "cycle_colour",
        panel_by     = "arc",
        ncol         = 3,
        show_labels  = FALSE,
        show_arrow   = FALSE,
        display      = circ_display(zero = 0))
p

+ Bootstrap CI arc added first so it sits behind heading markers:

p <- p + add_heading_interval(hd_cc, colour_col = "arc", colour = "black",
                               stat = "bootstrap_ci", boot_reps = 999L)
p

+ Heading vectors (dotted lines from inner crossing to perimeter):

p <- p + add_heading_vectors(hd_cc)
p
#> Warning: Removed 25 rows containing missing values or values outside the scale range
#> (`geom_segment()`).

+ Heading points on top of the CI arc:

p <- p + add_heading_points(hd_cc, size = 4)
p
#> Warning: Removed 25 rows containing missing values or values outside the scale range
#> (`geom_segment()`).
#> Warning: Removed 25 rows containing missing values or values outside the scale range
#> (`geom_point()`).

p <- p + add_heading_arrow(hd_cc, colour_col = "arc", colour = "black")
p
#> Warning: Removed 25 rows containing missing values or values outside the scale range
#> (`geom_segment()`).
#> Warning: Removed 25 rows containing missing values or values outside the scale range
#> (`geom_point()`).

Each panel shows trajectories and heading markers coloured by per-trajectory cycling palette, a bootstrap CI arc at radius 1.05 in the condition colour (rendered behind the heading points), a solid mean direction arrow, and degree labels. All in clock convention (0° = toward reference, clockwise). Use strip_position = "inside" to place the label inside the plot area, or strip_labels = FALSE to suppress it.

Circular Density Overlays

Three functions handle directional density overlays; they are designed to compose cleanly with any density source:

Function Role
compute_circular_density() Estimates density from raw headings; returns a plain data frame of (theta, density) pairs
add_circular_density() Renders any (theta, density) data frame as a radial overlay — agnostic to how the density was produced
add_heading_density() Convenience wrapper: calls the two above in sequence

Three built-in methods are available in compute_circular_density(): "vonmises" (MLE via circular::mle.vonmises()), "kernel" (circular KDE), and "histogram" (bin counts). Because the computation and rendering steps are separate, the density column can be replaced with values from any other model — including a Bayesian posterior predictive density from brms — before calling add_circular_density():

dens_df <- compute_circular_density(hd, colour_col = "arc", method = "vonmises")
# replace with Bayesian posterior: dens_df$density <- my_brms_fitted_density(dens_df$theta)
add_circular_density(dens_df, colour_col = "arc", fill = "grey80", alpha = 0.35)

The convenience wrapper add_heading_density() skips the intermediate step when no substitution is needed. The combined panel plot below uses it to shade a per-condition von Mises density alongside the heading vectors:

radiate(cpunctatus_cc,
        group_col    = "trial_id",
        colour_col   = "cycle_colour",
        panel_by     = "arc",
        ncol         = 3,
        show_labels  = FALSE,
        show_arrow   = FALSE,
        display      = circ_display(zero = 0)) +
  add_heading_density(hd_cc, colour_col = "arc",
                      method = "vonmises", scale = 0.4,
                      fill = "grey80", alpha = 0.35) +
  add_heading_vectors(hd_cc) +
  add_heading_arrow(hd_cc, colour_col = "arc", colour = "black")
#> Warning: Removed 25 rows containing missing values or values outside the scale range
#> (`geom_segment()`).

The shaded region is the von Mises density fitted to the crossing headings within each arc condition. A narrower, taller peak indicates more concentrated directional responses. Switch to method = "kernel" for a non-parametric estimate, or method = "histogram" for a raw count display.

Bootstrap Confidence Band

compute_circular_density() with boot_reps > 0 runs a non-parametric bootstrap: heading samples are drawn with replacement, a von Mises MLE is fitted to each replicate, and the density is re-evaluated on the same angular grid. The boot_alpha / 2 and 1 - boot_alpha / 2 quantiles across replicates become density_lower and density_upper columns, which add_circular_density() renders as a shaded band around the fitted curve.

dens_boot <- compute_circular_density(hd, colour_col = "arc",
                                      method = "vonmises",
                                      boot_reps = 499L, boot_alpha = 0.05,
                                      n_theta = 300L)

radiate(cpunctatus,
        group_col    = "trial_id",
        colour_cycle = 10,
        panel_by     = "arc",
        ncol         = 3,
        show_labels  = FALSE,
        show_arrow   = FALSE,
        display      = circ_display(zero = 0)) +
  add_circular_density(dens_boot, colour_col = "arc",
                       scale = 0.4, fill = "grey80", alpha = 0.35,
                       ci_fill = "grey60", ci_alpha = 0.35) +
  add_heading_arrow(hd, colour_col = "arc", colour = "black")

The darker band is the 95% bootstrap confidence interval for the fitted von Mises density. A wider band reflects greater parametric uncertainty, typically seen in the smaller or more diffuse conditions. The density_lower and density_upper columns in dens_boot can be replaced with interval values from a Bayesian model (e.g. the 2.5th and 97.5th percentiles of a brms posterior predictive distribution) before passing to add_circular_density().

Circular Summary Statistics

compute_circ_mean() returns the per-condition statistics behind the arrows above:

compute_circ_mean(hd, colour_col = "arc")[, c("arc", "mean_dir", "resultant_R")]
#>   arc  mean_dir resultant_R
#> 1   0 2.4441971  0.21852376
#> 2   5 6.1710354  0.03518184
#> 3  10 1.3122762  0.27298867
#> 4  15 6.1535219  0.25745294
#> 5  20 3.7683984  0.10968390
#> 6  30 0.8051961  0.16285026
#> 7  40 3.3258479  0.12904693
#> 8  50 1.6405047  0.09148952

For within-trial path consistency (tortuosity), circ_summary() operates on the step-by-step angle distribution:

circ_summary(ts_demo)
#>           id   n t_start t_end   mean_dir resultant_R kappa
#> 1     10_1_1  81       1    81 0.11601277  0.83986765    NA
#> 2    10_10_1 299       1   299 2.27816423  0.15483083    NA
#> 3    10_11_1  65      14    78 0.25433201  0.66719920    NA
#> 4    10_12_1  48       2    49 2.30699848  0.90377927    NA
#> 5    10_13_1  80      22   101 0.96510683  0.53748429    NA
#> 6    10_14_1  30       1    30 4.76335993  0.97186701    NA
#> 7    10_15_1  54       2    55 3.52077381  0.93703586    NA
#> 8    10_16_1  78       2    79 3.07994139  0.75350741    NA
#> 9    10_17_1  47       1    47 3.57548520  0.96788712    NA
#> 10   10_19_1 282      18   299 1.95265552  0.37352800    NA
#> 11    10_2_1  48       1    48 2.38407618  0.92287205    NA
#> 12   10_20_1 257      35   291 3.28735826  0.06865973    NA
#> 13   10_23_1 285      15   299 3.17651959  0.95049488    NA
#> 14   10_24_1 299       1   299 4.41001073  0.99114749    NA
#> 15   10_26_1 297       3   299 0.56922008  0.98833710    NA
#> 16   10_27_1 287      13   299 1.27801742  0.17608487    NA
#> 17   10_28_1  70       1    70 0.24722515  0.99017916    NA
#> 18   10_29_1 160      33   192 5.88168170  0.89922506    NA
#> 19    10_3_1 113       1   113 2.82658773  0.36596253    NA
#> 20   10_33_1 292       8   299 2.63078292  0.99680196    NA
#> 21   10_35_1 285      15   299 3.71725585  0.98345748    NA
#> 22   10_39_1 209       8   216 1.74020125  0.87543282    NA
#> 23   10_41_1 246      54   299 4.40674054  0.66683690    NA
#> 24   10_45_1 297       3   299 1.94073553  0.96305715    NA
#> 25    10_5_1  60       2    61 1.94603101  0.71894766    NA
#> 26    10_6_1  91       1    91 5.38407117  0.73923539    NA
#> 27    10_7_1  56       1    56 3.03029422  0.81454694    NA
#> 28    10_8_1  87       1    87 0.28440324  0.98625686    NA
#> 29    10_9_1 299       1   299 1.28909699  0.44454476    NA
#> 30   15_10_1 299       1   299 2.55980784  0.94235363    NA
#> 31   15_11_1 299       1   299 6.22819541  0.99982898    NA
#> 32   15_13_1  86       1    86 2.34142569  0.97038308    NA
#> 33   15_14_1 299       1   299 4.50120997  0.96268696    NA
#> 34   15_16_1 299       1   299 2.22838041  0.05176624    NA
#> 35   15_19_1 136       1   136 1.49830203  0.97451676    NA
#> 36    15_2_1 298       2   299 3.78567731  0.56143361    NA
#> 37   15_21_1 299       1   299 0.30348015  0.99696169    NA
#> 38   15_23_1 299       1   299 5.46169359  0.50809640    NA
#> 39   15_24_1 299       1   299 6.01136043  0.99737845    NA
#> 40   15_25_1 201      15   215 3.49231678  0.24577237    NA
#> 41   15_26_1 180       4   183 4.59130526  0.99325588    NA
#> 42   15_27_1 299       1   299 3.27544567  0.51244674    NA
#> 43   15_28_1 205       1   205 2.51823083  0.94955008    NA
#> 44   15_29_1 181       1   181 1.40296402  0.30838298    NA
#> 45    15_3_1 263      20   282 4.14828843  0.90299967    NA
#> 46   15_30_1 280       1   280 2.98748265  0.99798753    NA
#> 47   15_32_1 299       1   299 5.96415656  0.98966372    NA
#> 48   15_35_1 233       1   233 0.20853032  0.78788735    NA
#> 49   15_36_1 299       1   299 2.77136926  0.47808052    NA
#> 50   15_41_1 192       6   197 0.45932573  0.94218791    NA
#> 51   15_42_1  92       1    92 3.50831648  0.97564825    NA
#> 52   15_44_1 225       7   231 3.10874826  0.32496901    NA
#> 53   15_47_1 225       1   225 0.17240450  0.99548491    NA
#> 54   15_48_1 225       1   225 0.18326845  0.99518116    NA
#> 55   15_49_1 299       1   299 1.67764329  0.99937551    NA
#> 56   15_50_1 299       1   299 1.07588619  0.42856981    NA
#> 57   15_51_1 181       1   181 6.13306449  0.99791415    NA
#> 58    15_7_1 175       3   177 0.24885314  0.98512977    NA
#> 59    15_9_1 279       3   281 5.14054126  0.72395950    NA
#> 60   20_10_1 299       1   299 3.90847302  0.95728614    NA
#> 61   20_11_1 284       8   291 5.65755647  0.77852741    NA
#> 62   20_13_1 287       2   288 4.01880340  0.62800624    NA
#> 63   20_14_1 135       1   135 0.25507384  0.97496332    NA
#> 64   20_16_1 216       8   223 5.39642116  0.92479381    NA
#> 65   20_18_1 283       2   284 5.04291139  0.71006271    NA
#> 66   20_19_1  78      21    98 1.79807786  0.97876779    NA
#> 67    20_2_1 227       1   227 1.51703975  0.89133744    NA
#> 68   20_20_1  85       1    85 2.68502012  0.75287781    NA
#> 69   20_22_1 299       1   299 0.18918312  0.97859808    NA
#> 70   20_24_1 299       1   299 4.19233539  0.97193537    NA
#> 71   20_26_1 274       7   280 3.01169447  0.50723092    NA
#> 72   20_27_1  84      10    93 2.90554888  0.65560451    NA
#> 73   20_28_1 246       5   250 2.64039413  0.94119258    NA
#> 74   20_29_1  41      59    99 0.92989363  0.95902177    NA
#> 75    20_3_1 241      43   283 4.13256041  0.62193439    NA
#> 76   20_30_1 299       1   299 4.06111336  0.99762883    NA
#> 77   20_33_1 105      40   144 0.42291825  0.95171682    NA
#> 78   20_35_1 299       1   299 0.06807526  0.99863680    NA
#> 79   20_36_1 274       1   274 0.95650996  0.51392567    NA
#> 80   20_39_1  71      81   151 2.29183469  0.93249560    NA
#> 81   20_41_1 202      50   251 6.19127720  0.96381695    NA
#> 82   20_47_1 120      62   181 5.04036796  0.31089917    NA
#> 83   20_48_1 110      16   125 3.55333429  0.91803621    NA
#> 84    20_5_1 269      31   299 0.51898415  0.92700037    NA
#> 85    20_7_1 297       3   299 2.59039500  0.94274263    NA
#> 86    20_9_1 299       1   299 5.16322703  0.99372483    NA
#> 87    30_1_1  73      20    92 3.39275362  0.90071602    NA
#> 88   30_10_1  38       1    38 6.07733907  0.99087223    NA
#> 89   30_11_1  60       1    60 4.20293090  0.93157618    NA
#> 90   30_12_1  52       7    58 3.29319603  0.96586000    NA
#> 91   30_13_1  50      35    84 4.62774407  0.93530935    NA
#> 92   30_14_1  34       1    34 2.06777042  0.99308649    NA
#> 93   30_15_1  41       1    41 6.13222047  0.98975264    NA
#> 94   30_16_1  50       1    50 4.01534137  0.94753425    NA
#> 95   30_17_1  51       1    51 5.36276056  0.89836243    NA
#> 96   30_18_1 284      16   299 2.28433577  0.99345023    NA
#> 97   30_19_1  71      16    86 2.73210591  0.90590373    NA
#> 98    30_2_1  73      12    84 5.47578231  0.39447530    NA
#> 99   30_20_1 129       6   134 1.77295330  0.40866735    NA
#> 100  30_24_1 299       1   299 5.43793062  0.99482668    NA
#> 101  30_26_1 177       1   177 3.40104741  0.96939648    NA
#> 102  30_27_1 216      27   242 5.61946327  0.67400152    NA
#> 103  30_28_1 139      19   157 0.03150473  0.97900913    NA
#> 104  30_29_1 200      53   252 3.00899101  0.96466914    NA
#> 105   30_3_1  56       7    62 4.99839006  0.70272866    NA
#> 106  30_30_1 178       1   178 0.38647092  0.98072191    NA
#> 107  30_32_1 182      72   253 0.03134930  0.81603080    NA
#> 108  30_33_1 138      13   150 6.02909798  0.97977956    NA
#> 109  30_36_1 299       1   299 4.44004481  0.99022264    NA
#> 110  30_39_1 199      44   242 0.72661645  0.90772186    NA
#> 111  30_42_1  58       1    58 2.05768991  0.99873914    NA
#> 112  30_43_1 241      50   290 2.16904391  0.98470451    NA
#> 113  30_44_1 169      26   194 0.64805093  0.73352021    NA
#> 114  30_46_1 273      27   299 2.18430623  0.63729476    NA
#> 115  30_47_1 288      12   299 4.94467482  0.62368005    NA
#> 116  30_49_1 294       6   299 2.20328778  0.87515734    NA
#> 117   30_5_1  53       1    53 5.61673329  0.81208153    NA
#> 118  30_50_1  65       1    65 5.81959476  0.96142054    NA
#> 119  30_51_1 205       9   213 3.44708860  0.05506557    NA
#> 120   30_6_1  48       6    53 5.83079548  0.98006564    NA
#> 121   30_7_1  39       1    39 2.32792086  0.96191934    NA
#> 122   30_8_1 129       1   129 0.68981333  0.35496752    NA
#> 123   30_9_1  90       4    93 6.16244951  0.64077539    NA
#> 124  40_10_1 133      10   142 6.00447436  0.40888368    NA
#> 125  40_11_1 299       1   299 6.11919465  0.99801351    NA
#> 126  40_13_1 191       2   192 5.59272404  0.36390208    NA
#> 127  40_14_1 259       7   265 2.43980261  0.85412471    NA
#> 128  40_16_1 221      31   251 1.98421263  0.95133761    NA
#> 129  40_17_1 104       2   105 6.16532152  0.88894377    NA
#> 130  40_18_1 116     145   260 0.79804169  0.72171201    NA
#> 131  40_19_1  90       9    98 0.26186201  0.98727821    NA
#> 132   40_2_1 118      12   129 6.12041950  0.77375815    NA
#> 133  40_24_1 205       2   206 1.04904279  0.87601778    NA
#> 134  40_26_1 135      10   144 3.71903067  0.80816710    NA
#> 135  40_27_1 138       3   140 0.56835462  0.71555686    NA
#> 136   40_3_1 124      28   151 0.17927597  0.78604298    NA
#> 137  40_30_1  99       3   101 5.81357489  0.96389623    NA
#> 138  40_32_1  96     128   223 0.08177663  0.67625013    NA
#> 139  40_33_1 126       3   128 0.46419774  0.80410230    NA
#> 140  40_49_1  46       2    47 6.17303314  0.99806524    NA
#> 141   40_5_1  94       4    97 0.18349674  0.89185689    NA
#> 142   40_6_1 277       4   280 6.20636421  0.57290195    NA
#> 143   40_7_1 264      36   299 0.10986755  0.99581738    NA
#> 144   5_10_1 201       3   203 1.32889449  0.89693381    NA
#> 145   5_11_1 298       2   299 3.18129611  0.59089708    NA
#> 146   5_12_1 158       2   159 0.39855843  0.94047525    NA
#> 147   5_13_1 191       6   196 0.39832703  0.73418987    NA
#> 148   5_16_1 208       1   208 3.56967734  0.93046175    NA
#> 149   5_17_1 142       1   142 5.65665650  0.99938804    NA
#> 150   5_19_1 208       1   208 1.51114689  0.97325096    NA
#> 151    5_2_1 137     163   299 0.22200488  0.91587458    NA
#> 152   5_21_1 260       1   260 5.07208979  0.98465297    NA
#> 153   5_22_1 297       3   299 0.29515255  0.61239287    NA
#> 154   5_23_1 299       1   299 5.68981823  0.99585195    NA
#> 155   5_25_1 280      20   299 3.93085155  0.39653127    NA
#> 156   5_26_1 296       3   298 6.24589318  0.93833171    NA
#> 157   5_29_1 184       1   184 6.05913072  0.40361072    NA
#> 158    5_3_1 261       2   262 3.82717578  0.79910149    NA
#> 159   5_32_1 299       1   299 2.21265215  0.99370379    NA
#> 160   5_35_1 299       1   299 5.71977087  0.99512318    NA
#> 161   5_36_1 299       1   299 2.27394875  0.98980596    NA
#> 162   5_41_1 105       1   105 5.31642185  0.98211878    NA
#> 163   5_42_1 191       1   191 6.06074139  0.28949964    NA
#> 164   5_44_1 299       1   299 5.98267984  0.94016727    NA
#> 165   5_45_1 299       1   299 5.53354052  0.99502366    NA
#> 166   5_46_1 299       1   299 3.06873896  0.85861001    NA
#> 167   5_47_1 149       1   149 5.96766840  0.95295904    NA
#> 168   5_48_1 274       1   274 3.09024609  0.97820154    NA
#> 169   5_49_1 299       1   299 1.14779301  0.94104956    NA
#> 170    5_5_1 269      31   299 3.51190462  0.31626160    NA
#> 171    5_6_1 256      44   299 0.12235832  0.85720948    NA
#> 172    5_7_1 215       1   215 4.77971702  0.89910205    NA
#> 173    5_8_1 123       1   123 4.22910498  0.98116197    NA
#> 174    5_9_1 299       1   299 1.15330738  0.68674076    NA
#> 175   50_1_1  65       1    65 6.19771559  0.99913654    NA
#> 176  50_10_1 194       4   197 2.28619987  0.87947832    NA
#> 177  50_11_1 256       2   257 6.25526767  0.34406464    NA
#> 178  50_14_1 132       2   133 6.03483957  0.98049111    NA
#> 179  50_16_1 298       2   299 6.25805730  0.88495925    NA
#> 180   50_2_1  62      11    72 3.21592025  0.98097149    NA
#> 181  50_20_1 155      73   227 0.73455550  0.92704569    NA
#> 182  50_22_1  77      54   130 0.34123154  0.98576413    NA
#> 183  50_24_1 282      18   299 1.45208632  0.96835103    NA
#> 184  50_25_1 123      37   159 5.75907144  0.96936065    NA
#> 185  50_26_1 124      19   142 4.35624530  0.94413496    NA
#> 186  50_27_1 130       1   130 3.08946442  0.99090064    NA
#> 187  50_28_1 229      11   239 3.45828698  0.98948172    NA
#> 188  50_29_1 299       1   299 0.66366149  0.98645976    NA
#> 189   50_3_1 125       1   125 0.15990034  0.95491684    NA
#> 190  50_32_1 288      12   299 1.53825754  0.98379715    NA
#> 191  50_33_1 261       1   261 6.05266750  0.99283917    NA
#> 192  50_39_1  59       6    64 5.63262124  0.99709946    NA
#> 193  50_40_1 133       3   135 5.97765964  0.85551295    NA
#> 194  50_41_1 103      38   140 0.92126074  0.58873647    NA
#> 195  50_42_1 107      59   165 6.18312554  0.99447837    NA
#> 196  50_43_1 102       2   103 6.23777013  0.99124588    NA
#> 197  50_44_1 203      35   237 0.26364345  0.97867479    NA
#> 198  50_50_1 179       6   184 4.90491763  0.08519778    NA
#> 199   50_7_1 299       1   299 6.26336680  0.99001778    NA
#> 200   50_9_1  90       1    90 0.36394343  0.92791231    NA
#> 201  con_1_1 147       1   147 1.29473659  0.98007630    NA
#> 202 con_10_1 120       8   127 3.47730318  0.90937123    NA
#> 203 con_11_1 118       1   118 5.25384538  0.95278182    NA
#> 204 con_12_1 129      45   173 0.38534017  0.92386876    NA
#> 205 con_13_1 149       1   149 4.24942089  0.97254953    NA
#> 206 con_14_1 155      52   206 1.77863176  0.87041196    NA
#> 207 con_15_1 118       1   118 4.38496821  0.98996964    NA
#> 208 con_16_1 221       6   226 5.20647224  0.84991454    NA
#> 209 con_19_1 266      34   299 5.01384177  0.27536779    NA
#> 210  con_2_1 136      24   159 4.37473303  0.91887910    NA
#> 211 con_20_1 299       1   299 1.69562541  0.37926374    NA
#> 212 con_23_1 115       2   116 3.73039640  0.79732483    NA
#> 213 con_24_1 291       1   291 5.38453689  0.99489603    NA
#> 214 con_25_1 142       1   142 2.26654959  0.96467020    NA
#> 215 con_26_1 277       2   278 3.37470337  0.99053493    NA
#> 216 con_27_1 110       4   113 0.04989460  0.79092220    NA
#> 217 con_28_1 256       3   258 0.36443605  0.20171194    NA
#> 218 con_29_1 290       1   290 2.80214800  0.87108892    NA
#> 219  con_3_1 130      43   172 0.27829493  0.98783410    NA
#> 220 con_33_1  45       1    45 2.09379998  0.99253604    NA
#> 221 con_35_1 294       3   296 5.55192506  0.97266365    NA
#> 222 con_36_1 299       1   299 5.19699304  0.95996382    NA
#> 223 con_39_1 128       9   136 4.22334572  0.99369898    NA
#> 224 con_41_1 137      10   146 5.64130692  0.74099920    NA
#> 225 con_44_1 136       1   136 3.44949153  0.79236089    NA
#> 226 con_47_1 281      19   299 1.04005905  0.19074572    NA
#> 227 con_48_1 164      32   195 5.76965270  0.59891339    NA
#> 228 con_49_1 299       1   299 0.11174903  0.99885384    NA
#> 229  con_5_1 109       2   110 1.96347912  0.97036793    NA
#> 230 con_50_1 299       1   299 3.56823469  0.19359006    NA
#> 231 con_51_1 196      11   206 3.64874010  0.99212806    NA
#> 232  con_6_1 114       1   114 3.04482025  0.99546073    NA
#> 233  con_7_1 134      19   152 2.11124516  0.87734213    NA
#> 234  con_8_1 213      68   280 0.81824501  0.98924503    NA
#> 235  con_9_1 299       1   299 6.20059653  0.33178954    NA

High resultant lengths (close to 1) indicate very consistent step directions within a trial.

Alternative Heading Rules

derive_headings() supports fourteen built-in rules. "crossing" is well suited to echinoderm-style tracks — moderate tortuosity, consistent outward movement — but other rules may be more appropriate depending on the taxon and experimental design. Use list_heading_rules() to see all available names; custom rules can be added with register_heading_rule().

Two parameter-free alternatives are especially useful:

Rule What it returns Typical use
"distal" Angular position of the frame with the largest radius Straight outward paths (dung beetles, ballistic homing)
"net" Direction of the start-to-end displacement vector Sinuous or multi-phase paths where only net displacement matters

distal

The distal rule takes atan2(y, x) at the frame where the animal is farthest from the arena centre. It requires no ring parameters and never returns NA because every trial has a most-distal frame.

hd_distal <- derive_headings(cpunctatus, rule = "distal",
                              return_coords = TRUE)
names(hd_distal)[names(hd_distal) == "id"] <- "trial_id"
# join the target half-width (arc) from the dataset, as for the crossing rule
hd_distal     <- merge(hd_distal, arc_map, by = "trial_id")
hd_distal$arc <- factor(hd_distal$arc)
attr(hd_distal, "display") <- circ_display(zero = 0)

Per-condition resultant lengths from "distal" are very close to those from "crossing", consistent with millipede tracks being fairly direct:

sm_cross  <- compute_circ_mean(hd,       colour_col = "arc")[, c("arc", "resultant_R")]
sm_distal <- compute_circ_mean(hd_distal, colour_col = "arc")[, c("arc", "resultant_R")]
names(sm_cross)[2]  <- "crossing"
names(sm_distal)[2] <- "distal"
merge(sm_cross, sm_distal, by = "arc")
#>   arc   crossing     distal
#> 1   0 0.21852376 0.23531457
#> 2  10 0.27298867 0.07055967
#> 3  15 0.25745294 0.24477975
#> 4  20 0.10968390 0.07326231
#> 5  30 0.16285026 0.12936762
#> 6  40 0.12904693 0.06536414
#> 7   5 0.03518184 0.07181952
#> 8  50 0.09148952 0.15790182

Visualising the distal headings on the per-condition panel confirms that the spatial pattern is consistent with the crossing method:

cpunctatus_cc_d        <- cpunctatus
cpunctatus_cc_d@data   <- assign_cycle_colours(cpunctatus@data,
                                              id_col    = "trial_id",
                                              n         = 10,
                                              panel_col = "arc")
colour_map_d <- unique(cpunctatus_cc_d@data[, c("trial_id", "cycle_colour")])
hd_distal_cc <- merge(hd_distal, colour_map_d, by = "trial_id", all.x = TRUE)
attr(hd_distal_cc, "display")    <- attr(hd_distal, "display", exact = TRUE)
attr(hd_distal_cc, "colour_col") <- "cycle_colour"

radiate(cpunctatus_cc_d,
        group_col   = "trial_id",
        colour_col  = "cycle_colour",
        panel_by    = "arc",
        ncol        = 3,
        show_labels = FALSE,
        show_arrow  = FALSE,
        display     = circ_display(zero = 0)) +
  add_heading_interval(hd_distal_cc, colour_col = "arc", colour = "black",
                       stat = "bootstrap_ci", boot_reps = 999L) +
  add_heading_points(hd_distal_cc, size = 4) +
  add_heading_arrow(hd_distal_cc, colour_col = "arc", colour = "black")

Because millipede tracks are nearly radial, the heading estimates agree closely across methods. For more sinuous species (moths, pigeons) the choice of rule has a larger influence on the result.

net

The net rule returns the direction of the vector from the first recorded position to the last, ignoring all intermediate points. It is the fastest possible estimate and is the natural match for GPS vanishing-bearing studies where only the release and final observed positions are available.

hd_net <- derive_headings(cpunctatus, rule = "net")
names(hd_net)[names(hd_net) == "id"] <- "trial_id"
# join the target half-width (arc) from the dataset, as for the crossing rule
hd_net     <- merge(hd_net, arc_map, by = "trial_id")
hd_net$arc <- factor(hd_net$arc)

sm_net <- compute_circ_mean(hd_net, colour_col = "arc")[, c("arc", "resultant_R")]
names(sm_net)[2] <- "net"
Reduce(function(a, b) merge(a, b, by = "arc"),
       list(sm_cross, sm_distal, sm_net))
#>   arc   crossing     distal        net
#> 1   0 0.21852376 0.23531457 0.22962978
#> 2  10 0.27298867 0.07055967 0.05152922
#> 3  15 0.25745294 0.24477975 0.23894631
#> 4  20 0.10968390 0.07326231 0.02827464
#> 5  30 0.16285026 0.12936762 0.11498421
#> 6  40 0.12904693 0.06536414 0.06850022
#> 7   5 0.03518184 0.07181952 0.05236747
#> 8  50 0.09148952 0.15790182 0.15353222

For these tracks the three methods give very similar resultant lengths. Larger differences would be expected for species with complex search behaviour before committing to a direction.

Simulating Demo Data

simulate_tracks() generates synthetic trajectories without any files, useful for testing and demonstrations. The kappa parameter controls directedness:

sim_df <- simulate_tracks(
  conditions = data.frame(n_trials = c(5L, 5L), kappa = c(2, 8)),
  n_points = 150,
  seed = 42
)
ts_sim <- TrajSet(
  sim_df,
  id = "trial_id", time = "frame",
  angle = "rel_theta", x = "rel_x", y = "rel_y",
  angle_unit = "radians", normalize_xy = FALSE
)
radiate(ts_sim,
        group_col    = "trial_id",
        colour_cycle = 5,
        panel_by     = "condition",
        ncol         = 2,
        show_arrow   = TRUE)

The left panel (low kappa) shows tortuous paths; the right (high kappa) shows straighter, more directed tracks. The mean resultant arrow is computed independently per panel.