# The following code can be run in R to create your own jitter plot.
# Parameters can be changed in settings.
library(ggplot2)
# =======================
# Settings (edit these)
# =======================
cats <- c("A","B","C","D","E","F","G")
palette <- c("#FFFFFF","#5f0f40", "#9a031e", "#fb8b24", "#e36414", "#0f4c5c","#FFFFFF")
# Settings (edit these)
# =======================
cats <- c("A","B","C","D","E","F","G")
palette <- c("#FFFFFF","#5f0f40", "#9a031e", "#fb8b24", "#e36414", "#0f4c5c","#FFFFFF")
bar_height <- 10
points_per_bar <- 500
points_per_bar <- 500
# Motion & schedule
mode <- "random_walk" # "rerandomize" | "random_walk" | "ar1"
first_hold_frames <- 10 # initial frames with zero jitter
total_frames <- 50 # total frame count (including the hold)
sigma_margin <- 3 # ~99.7% stay within half-gap
rw_step_sd <- 0.02 # step SD for random_walk/ar1
ar_phi <- 0.85 # stickiness for ar1 (0..1)
mode <- "random_walk" # "rerandomize" | "random_walk" | "ar1"
first_hold_frames <- 10 # initial frames with zero jitter
total_frames <- 50 # total frame count (including the hold)
sigma_margin <- 3 # ~99.7% stay within half-gap
rw_step_sd <- 0.02 # step SD for random_walk/ar1
ar_phi <- 0.85 # stickiness for ar1 (0..1)
# Output & look
black_bg <- FALSE
fps <- 15
png_width_px <- 750 # exported PNG size
png_height_px <- 500
dpi <- 300 # used only to convert pixels -> inches for ggsave
dot_size <- .01
black_bg <- FALSE
fps <- 15
png_width_px <- 750 # exported PNG size
png_height_px <- 500
dpi <- 300 # used only to convert pixels -> inches for ggsave
dot_size <- .01
seed <- 123 # <- change per run if you want different results
set.seed(seed)
set.seed(seed)
# =======================
# Derived / setup
# =======================
png_w_in <- png_width_px / dpi
png_h_in <- png_height_px / dpi
bg <- if (black_bg) "black" else "white"
run_tag <- format(Sys.time(), "%Y%m%d_%H%M%S")
out_dir <- paste0("run_", run_tag, "_jitter")
file_prefix <- "frame"
out_dir <- paste0("run_", run_tag, "_jitter")
file_prefix <- "frame"
if (!dir.exists(out_dir)) dir.create(out_dir, recursive = TRUE)
unlink(list.files(out_dir, full.names = TRUE))
unlink(list.files(out_dir, full.names = TRUE))
# Safe max jitter (avoid overlap)
bar_positions <- seq_along(cats) # 1..K
min_gap <- min(diff(bar_positions)) # here = 1
j_max <- (min_gap/2) / sigma_margin
bar_positions <- seq_along(cats) # 1..K
min_gap <- min(diff(bar_positions)) # here = 1
j_max <- (min_gap/2) / sigma_margin
# Build a triangular "breathe" schedule for the non-hold portion
sched_len <- max(0, total_frames - first_hold_frames)
make_tri_schedule <- function(n, jmax) {
if (n <= 0) return(numeric(0))
if (n == 1) return(jmax/2) # trivial case
half <- ceiling((n + 1)/2)
up <- seq(0, 1, length.out = half)
down <- rev(up)[-1]
tri <- c(up, down)
tri[seq_len(n)] * jmax
}
breath <- make_tri_schedule(sched_len, j_max)
jitters <- c(rep(0, first_hold_frames), breath) # prepend the hold
sched_len <- max(0, total_frames - first_hold_frames)
make_tri_schedule <- function(n, jmax) {
if (n <= 0) return(numeric(0))
if (n == 1) return(jmax/2) # trivial case
half <- ceiling((n + 1)/2)
up <- seq(0, 1, length.out = half)
down <- rev(up)[-1]
tri <- c(up, down)
tri[seq_len(n)] * jmax
}
breath <- make_tri_schedule(sched_len, j_max)
jitters <- c(rep(0, first_hold_frames), breath) # prepend the hold
# Data: base points per bar
make_eps <- function(n) { # symmetric vector about 0
m <- n %/% 2
e <- rnorm(m)
c(e, -e, if (n %% 2 == 1) 0 else NULL)
}
make_eps <- function(n) { # symmetric vector about 0
m <- n %/% 2
e <- rnorm(m)
c(e, -e, if (n %% 2 == 1) 0 else NULL)
}
# Data: base points per bar (replace your current 'base' block)
base <- do.call(rbind, lapply(seq_along(cats), function(k) {
y_strat <- ((seq_len(points_per_bar) - 0.5) / points_per_bar) * bar_height
data.frame(
category = cats[k],
base_x = bar_positions[k],
base_y = y_strat
)
}))
base <- do.call(rbind, lapply(seq_along(cats), function(k) {
y_strat <- ((seq_len(points_per_bar) - 0.5) / points_per_bar) * bar_height
data.frame(
category = cats[k],
base_x = bar_positions[k],
base_y = y_strat
)
}))
# State for walk/AR1
state_offsets <- rep(0, nrow(base))
# =======================
# Render frames
# =======================
for (i in seq_along(jitters)) {
j <- jitters[i]
if (mode == "rerandomize") {
eps <- unlist(tapply(seq_len(nrow(base)), base$category,
function(idx) make_eps(length(idx))))
x <- base$base_x + eps * j
} else if (mode == "random_walk") {
if (i > first_hold_frames) {
steps <- rnorm(length(state_offsets), 0, rw_step_sd)
state_offsets <- pmin(pmax(state_offsets + steps, -j_max), j_max)
} else {
state_offsets[] <- 0
}
x <- base$base_x + state_offsets
} else if (mode == "ar1") {
if (i > first_hold_frames) {
noise <- rnorm(length(state_offsets), 0, rw_step_sd)
state_offsets <- ar_phi * state_offsets + noise
state_offsets <- pmin(pmax(state_offsets, -j_max), j_max)
} else {
state_offsets[] <- 0
}
x <- base$base_x + state_offsets
} else stop("Unknown mode: use 'rerandomize', 'random_walk', or 'ar1'.")
df <- transform(base, x = x, y = base_y)
p <- ggplot(df, aes(x = x, y = y, color = category)) +
geom_point(alpha = 0.7, size = dot_size) +
scale_color_manual(values = palette) +
coord_cartesian(ylim = c(0, bar_height)) +
theme_void() +
theme(
legend.position = "none",
panel.background = element_rect(fill = bg, color = NA),
plot.background = element_rect(fill = bg, color = NA)
)
ggsave(
filename = file.path(out_dir, sprintf("%s_%03d.png", file_prefix, i)),
plot = p, width = png_w_in, height = png_h_in, dpi = dpi, bg = bg
)
}
# Render frames
# =======================
for (i in seq_along(jitters)) {
j <- jitters[i]
if (mode == "rerandomize") {
eps <- unlist(tapply(seq_len(nrow(base)), base$category,
function(idx) make_eps(length(idx))))
x <- base$base_x + eps * j
} else if (mode == "random_walk") {
if (i > first_hold_frames) {
steps <- rnorm(length(state_offsets), 0, rw_step_sd)
state_offsets <- pmin(pmax(state_offsets + steps, -j_max), j_max)
} else {
state_offsets[] <- 0
}
x <- base$base_x + state_offsets
} else if (mode == "ar1") {
if (i > first_hold_frames) {
noise <- rnorm(length(state_offsets), 0, rw_step_sd)
state_offsets <- ar_phi * state_offsets + noise
state_offsets <- pmin(pmax(state_offsets, -j_max), j_max)
} else {
state_offsets[] <- 0
}
x <- base$base_x + state_offsets
} else stop("Unknown mode: use 'rerandomize', 'random_walk', or 'ar1'.")
df <- transform(base, x = x, y = base_y)
p <- ggplot(df, aes(x = x, y = y, color = category)) +
geom_point(alpha = 0.7, size = dot_size) +
scale_color_manual(values = palette) +
coord_cartesian(ylim = c(0, bar_height)) +
theme_void() +
theme(
legend.position = "none",
panel.background = element_rect(fill = bg, color = NA),
plot.background = element_rect(fill = bg, color = NA)
)
ggsave(
filename = file.path(out_dir, sprintf("%s_%03d.png", file_prefix, i)),
plot = p, width = png_w_in, height = png_h_in, dpi = dpi, bg = bg
)
}
message(sprintf(
"No-overlap j_max (≈%dσ) = %.4f | first_hold_frames = %d | total_frames = %d | mode = %s",
sigma_margin, j_max, first_hold_frames, total_frames, mode
))
"No-overlap j_max (≈%dσ) = %.4f | first_hold_frames = %d | total_frames = %d | mode = %s",
sigma_margin, j_max, first_hold_frames, total_frames, mode
))
# =======================
# GIF (gifski)
# =======================
if (!requireNamespace("gifski", quietly = TRUE)) install.packages("gifski")
pngs <- Sys.glob(file.path(out_dir, paste0(file_prefix, "_*.png")))
pngs <- pngs[order(pngs)]
# GIF (gifski)
# =======================
if (!requireNamespace("gifski", quietly = TRUE)) install.packages("gifski")
pngs <- Sys.glob(file.path(out_dir, paste0(file_prefix, "_*.png")))
pngs <- pngs[order(pngs)]
# Mirror: forward + reverse (skip last to avoid duplicate pause)
pngs_loop <- c(pngs, rev(pngs[-length(pngs)]))
pngs_loop <- c(pngs, rev(pngs[-length(pngs)]))
gifski::gifski(
pngs_loop,
gif_file = file.path(out_dir, "jitter.gif"),
width = png_width_px, height = png_height_px,
delay = 1/fps
)
pngs_loop,
gif_file = file.path(out_dir, "jitter.gif"),
width = png_width_px, height = png_height_px,
delay = 1/fps
)