From 87ad9e2cb3b032f91b27d7a257f902f09d47faf9 Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Sun, 2 Aug 2026 17:46:26 -0400 Subject: [PATCH 1/5] Add Fourier epicycles Shiny app --- fourier-epicycles/app.R | 275 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 fourier-epicycles/app.R diff --git a/fourier-epicycles/app.R b/fourier-epicycles/app.R new file mode 100644 index 0000000..37f9f94 --- /dev/null +++ b/fourier-epicycles/app.R @@ -0,0 +1,275 @@ +# packages ---------------------------------------------------------------- +library(shiny) +library(bslib) +library(markdown) + +# theme ------------------------------------------------------------------- +apptheme <- bs_theme() +sidebar <- purrr::partial(bslib::sidebar, width = 300) +card <- purrr::partial(bslib::card, full_screen = TRUE, wrapper = purrr::partial(bslib::card_body, padding = 0)) +primary <- unname(bs_get_variables(apptheme, "primary")) + +# app options ------------------------------------------------------------- +N_POINTS <- 256 +MAX_COMPONENTS <- 60 + +resample_path <- function(x, y, n = N_POINTS) { + x <- c(x, x[1]) + y <- c(y, y[1]) + distance <- c(0, cumsum(sqrt(diff(x)^2 + diff(y)^2))) + target <- seq(0, max(distance), length.out = n + 1)[-(n + 1)] + data.frame( + x = approx(distance, x, target, ties = "ordered")$y, + y = approx(distance, y, target, ties = "ordered")$y + ) +} + +shape_path <- function(shape, n = N_POINTS) { + path <- switch( + shape, + circle = { + angle <- seq(0, 2 * pi, length.out = 1001)[-1001] + resample_path(cos(angle), sin(angle), n) + }, + square = resample_path(c(-1, 1, 1, -1), c(-1, -1, 1, 1), n), + star = { + angle <- pi / 2 + seq(0, 2 * pi, length.out = 11)[-11] + radius <- rep(c(1, 0.42), 5) + resample_path(radius * cos(angle), radius * sin(angle), n) + }, + heart = { + angle <- seq(0, 2 * pi, length.out = 1001)[-1001] + resample_path( + 16 * sin(angle)^3, + 13 * cos(angle) - 5 * cos(2 * angle) - + 2 * cos(3 * angle) - cos(4 * angle), + n + ) + } + ) + + path$x <- path$x - mean(path$x) + path$y <- path$y - mean(path$y) + scale <- max(sqrt(path$x^2 + path$y^2)) + transform(path, x = x / scale, y = y / scale) +} + +fourier_components <- function(path) { + z <- complex(real = path$x, imaginary = path$y) + coefficient <- fft(z) / length(z) + index <- seq_along(coefficient) - 1 + data.frame( + frequency = ifelse(index <= length(z) / 2, index, index - length(z)), + amplitude = Mod(coefficient), + phase = Arg(coefficient), + real = Re(coefficient), + imaginary = Im(coefficient), + energy = Mod(coefficient)^2 + ) +} + +reconstruct_path <- function(components, n = N_POINTS) { + time <- 2 * pi * (0:(n - 1)) / n + coefficient <- complex(real = components$real, imaginary = components$imaginary) + z <- exp(1i * outer(time, components$frequency)) %*% coefficient + data.frame(x = Re(z), y = Im(z)) +} + +# ui ---------------------------------------------------------------------- +ui <- page_fillable( + theme = apptheme, + padding = 0, + tags$head( + tags$script(src = "epicycles.js"), + tags$style(HTML( + ".epicycle-stage {height: 100%; min-height: 480px; background: var(--bs-body-bg);} + #epicycle-canvas {width: 100%; height: 100%; display: block;}" + )) + ), + layout_sidebar( + fillable = TRUE, + sidebar = sidebar( + title = "Fourier Epicycles", + withMathJax(), + selectInput( + "shape", + tags$small("Shape"), + c("Heart" = "heart", "Star" = "star", "Square" = "square", "Circle" = "circle") + ), + sliderInput("components", tags$small("Number of circles"), 1, MAX_COMPONENTS, 15, 1), + radioButtons( + "order", + tags$small("Circle order"), + c("Amplitude" = "amplitude", "Frequency" = "frequency"), + "amplitude", + inline = TRUE + ), + sliderInput("speed", tags$small("Animation speed"), 0.25, 2, 1, 0.25), + checkboxInput("playing", tags$small("Play animation"), TRUE), + actionButton("restart", "Restart", width = "100%"), + tags$hr(), + checkboxInput("show_circles", tags$small("Show circles"), TRUE), + checkboxInput("show_vectors", tags$small("Show vectors"), TRUE), + checkboxInput("show_original", tags$small("Show original path"), TRUE), + checkboxInput("show_trace", tags$small("Show reconstructed trace"), TRUE), + accordion( + open = FALSE, + accordion_panel( + "How it works", + tags$small(htmltools::includeMarkdown("readme.md")) + ) + ), + tags$small(htmltools::includeMarkdown("credits.md")) + ), + layout_columns( + col_widths = c(8, 4), + card( + card_header("Rotating Fourier vectors"), + card_body( + class = "p-0", + tags$div( + class = "epicycle-stage", + tags$canvas(id = "epicycle-canvas", `aria-label` = "Animated Fourier epicycles") + ) + ) + ), + card( + card_header(uiOutput("reconstruction_header")), + card_body(plotOutput("reconstruction", height = "100%")) + ) + ) + ) +) + +# server ------------------------------------------------------------------ +server <- function(input, output, session) { + path <- reactive(shape_path(input$shape)) + components <- reactive(fourier_components(path())) + + selected <- reactive({ + x <- components()[order(-components()$amplitude), ] + x <- head(x, input$components) + if (input$order == "frequency") x <- x[order(abs(x$frequency), x$frequency), ] + x + }) + + reconstruction <- reactive(reconstruct_path(selected(), nrow(path()))) + + quality <- reactive({ + ordered <- components()[order(-components()$amplitude), ] + total <- sum(ordered$energy) + list( + recovered = 100 * sum(selected()$energy) / total, + next_gain = 100 * ordered$energy[input$components + 1] / total + ) + }) + + send_data <- function() { + x <- selected() + original <- path() + session$sendCustomMessage( + "epicycles-data", + list( + components = lapply(seq_len(nrow(x)), \(i) list( + frequency = x$frequency[i], + amplitude = x$amplitude[i], + phase = x$phase[i] + )), + path = lapply(seq_len(nrow(original)), \(i) list( + x = original$x[i], + y = original$y[i] + )) + ) + ) + } + + send_options <- function() { + session$sendCustomMessage( + "epicycles-options", + list( + speed = input$speed, + playing = input$playing, + showCircles = input$show_circles, + showVectors = input$show_vectors, + showOriginal = input$show_original, + showTrace = input$show_trace + ) + ) + } + + session$onFlushed(function() { + send_data() + send_options() + }, once = TRUE) + + observeEvent( + list(input$shape, input$components, input$order), + send_data(), + ignoreInit = TRUE + ) + + observeEvent( + list( + input$speed, + input$playing, + input$show_circles, + input$show_vectors, + input$show_original, + input$show_trace + ), + send_options(), + ignoreInit = TRUE + ) + + observeEvent(input$restart, { + session$sendCustomMessage("epicycles-command", list(command = "restart")) + }) + + output$reconstruction_header <- renderUI({ + q <- quality() + tags$div( + class = "d-flex justify-content-between w-100", + tags$span("Reconstruction"), + tags$small( + class = "text-muted", + sprintf( + "%d circles · %.1f%% recovered · next +%.2f pp", + input$components, + q$recovered, + q$next_gain + ) + ) + ) + }) + + output$reconstruction <- renderPlot({ + original <- path() + estimate <- reconstruction() + par(mar = c(1, 1, 1, 1), bg = "transparent") + plot( + original$x, + original$y, + type = "l", + asp = 1, + axes = FALSE, + xlab = "", + ylab = "", + xlim = c(-1.1, 1.1), + ylim = c(-1.1, 1.1), + col = "grey75", + lwd = 3 + ) + lines(estimate$x, estimate$y, col = primary, lwd = 2) + legend( + "bottom", + c("Original", "Reconstructed"), + col = c("grey75", primary), + lwd = c(3, 2), + bty = "n", + horiz = TRUE, + cex = 0.8 + ) + }, res = 110) +} + +shinyApp(ui, server) From 48164ec51f8a39266529868d0318b67a8f3e795c Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Sun, 2 Aug 2026 17:46:47 -0400 Subject: [PATCH 2/5] Add native canvas epicycle animation --- fourier-epicycles/www/epicycles.js | 220 +++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 fourier-epicycles/www/epicycles.js diff --git a/fourier-epicycles/www/epicycles.js b/fourier-epicycles/www/epicycles.js new file mode 100644 index 0000000..632bd72 --- /dev/null +++ b/fourier-epicycles/www/epicycles.js @@ -0,0 +1,220 @@ +(() => { + const state = { + components: [], + original: [], + speed: 1, + playing: true, + showCircles: true, + showVectors: true, + showOriginal: true, + showTrace: true, + time: 0, + trace: [], + lastFrame: performance.now() + }; + + let canvas; + let context; + let resizeObserver; + + function colors() { + const style = getComputedStyle(document.documentElement); + + return { + primary: style.getPropertyValue("--bs-primary").trim() || "#0d6efd", + body: style.getPropertyValue("--bs-body-color").trim() || "#212529", + muted: style.getPropertyValue("--bs-secondary-color").trim() || "#6c757d", + border: style.getPropertyValue("--bs-border-color").trim() || "#dee2e6", + background: style.getPropertyValue("--bs-body-bg").trim() || "#ffffff" + }; + } + + function resizeCanvas() { + if (!canvas) return; + + const bounds = canvas.getBoundingClientRect(); + const ratio = window.devicePixelRatio || 1; + + canvas.width = Math.max(1, Math.floor(bounds.width * ratio)); + canvas.height = Math.max(1, Math.floor(bounds.height * ratio)); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + } + + function reset() { + state.time = 0; + state.trace = []; + state.lastFrame = performance.now(); + } + + function transformPoint(x, y, scale, centerX, centerY) { + return { + x: centerX + scale * x, + y: centerY - scale * y + }; + } + + function drawPath(points, scale, centerX, centerY, stroke, width, close = true) { + if (!points.length) return; + + context.beginPath(); + + points.forEach((point, index) => { + const p = transformPoint(point.x, point.y, scale, centerX, centerY); + if (index === 0) context.moveTo(p.x, p.y); + else context.lineTo(p.x, p.y); + }); + + if (close) context.closePath(); + context.strokeStyle = stroke; + context.lineWidth = width; + context.stroke(); + } + + function endpointAndChain() { + const chain = []; + let x = 0; + let y = 0; + + state.components.forEach(component => { + const previousX = x; + const previousY = y; + const angle = component.frequency * state.time + component.phase; + + x += component.amplitude * Math.cos(angle); + y += component.amplitude * Math.sin(angle); + + chain.push({ + x: previousX, + y: previousY, + radius: component.amplitude, + endX: x, + endY: y + }); + }); + + return { x, y, chain }; + } + + function drawFrame(now) { + if (!canvas || !context) { + requestAnimationFrame(drawFrame); + return; + } + + const width = canvas.clientWidth; + const height = canvas.clientHeight; + const palette = colors(); + + context.clearRect(0, 0, width, height); + context.fillStyle = palette.background; + context.fillRect(0, 0, width, height); + + const radii = state.components.reduce((sum, x) => sum + x.amplitude, 0); + const extent = Math.max(1.25, Math.min(2.5, radii)); + const scale = 0.42 * Math.min(width, height) / extent; + const centerX = width / 2; + const centerY = height / 2; + + if (state.showOriginal) { + drawPath(state.original, scale, centerX, centerY, palette.border, 2); + } + + const current = endpointAndChain(); + + if (state.showCircles) { + current.chain.forEach(item => { + const center = transformPoint(item.x, item.y, scale, centerX, centerY); + + context.beginPath(); + context.arc(center.x, center.y, item.radius * scale, 0, 2 * Math.PI); + context.strokeStyle = palette.border; + context.lineWidth = 1; + context.stroke(); + }); + } + + if (state.showVectors) { + current.chain.forEach(item => { + const start = transformPoint(item.x, item.y, scale, centerX, centerY); + const end = transformPoint(item.endX, item.endY, scale, centerX, centerY); + + context.beginPath(); + context.moveTo(start.x, start.y); + context.lineTo(end.x, end.y); + context.strokeStyle = palette.muted; + context.lineWidth = 1.5; + context.stroke(); + }); + } + + if (state.showTrace && state.trace.length > 1) { + drawPath(state.trace, scale, centerX, centerY, palette.primary, 2.5, false); + } + + const tip = transformPoint(current.x, current.y, scale, centerX, centerY); + context.beginPath(); + context.arc(tip.x, tip.y, 3.5, 0, 2 * Math.PI); + context.fillStyle = palette.primary; + context.fill(); + + const elapsed = Math.min(now - state.lastFrame, 100); + state.lastFrame = now; + + if (state.playing && state.components.length) { + state.time += elapsed * state.speed * 2 * Math.PI / 6000; + + if (state.time >= 2 * Math.PI) { + state.time %= 2 * Math.PI; + state.trace = []; + } + + state.trace.push({ x: current.x, y: current.y }); + if (state.trace.length > 2000) state.trace.shift(); + } + + requestAnimationFrame(drawFrame); + } + + function registerHandlers() { + if (!window.Shiny) { + window.setTimeout(registerHandlers, 50); + return; + } + + Shiny.addCustomMessageHandler("epicycles-data", message => { + state.components = message.components || []; + state.original = message.path || []; + reset(); + }); + + Shiny.addCustomMessageHandler("epicycles-options", message => { + Object.assign(state, message); + }); + + Shiny.addCustomMessageHandler("epicycles-command", message => { + if (message.command === "restart") reset(); + }); + } + + function initialize() { + canvas = document.getElementById("epicycle-canvas"); + if (!canvas) { + window.setTimeout(initialize, 50); + return; + } + + context = canvas.getContext("2d"); + resizeObserver = new ResizeObserver(resizeCanvas); + resizeObserver.observe(canvas); + resizeCanvas(); + requestAnimationFrame(drawFrame); + } + + registerHandlers(); + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initialize); + } else { + initialize(); + } +})(); From 287bbe8bf0c3ec97b2ebf425dae02e9543885db4 Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Sun, 2 Aug 2026 17:46:56 -0400 Subject: [PATCH 3/5] Add Fourier epicycles metadata --- fourier-epicycles/DESCRIPTION | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 fourier-epicycles/DESCRIPTION diff --git a/fourier-epicycles/DESCRIPTION b/fourier-epicycles/DESCRIPTION new file mode 100644 index 0000000..519b2ce --- /dev/null +++ b/fourier-epicycles/DESCRIPTION @@ -0,0 +1,3 @@ +Title: Fourier Epicycles +Description: Reconstruct a two-dimensional path with rotating vectors derived from its Fourier coefficients. +Categories: mathematics, visualization, simulation From 3b621c806878137d5ac710c0d4bb68f54e2ab7ae Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Sun, 2 Aug 2026 17:47:06 -0400 Subject: [PATCH 4/5] Document Fourier epicycles app --- fourier-epicycles/readme.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 fourier-epicycles/readme.md diff --git a/fourier-epicycles/readme.md b/fourier-epicycles/readme.md new file mode 100644 index 0000000..afc5fc9 --- /dev/null +++ b/fourier-epicycles/readme.md @@ -0,0 +1,17 @@ +A closed path can be treated as a complex signal: + +\\[ +z(t) = x(t) + i y(t) +\\] + +R samples the selected shape at equal distances and applies the discrete Fourier transform. Each coefficient becomes one rotating vector: + +- its **amplitude** is the circle radius; +- its **frequency** controls rotation speed and direction; +- its **phase** sets the initial angle. + +The vectors are added tip to tail. The final tip traces the reconstructed shape. Increasing the number of circles preserves more detail, while the reconstruction panel compares the result with the original path. + +The selected coefficients are always the largest by amplitude. **Circle order** only changes how the vector chain is displayed; it does not change the final reconstruction. + +The canvas animation is a small native JavaScript implementation inspired by [The Coding Train's Fourier epicycles tutorial](https://thecodingtrain.com/challenges/130-drawing-with-fourier-transform-and-epicycles/) and the visual explanation in [3Blue1Brown's Fourier series lesson](https://www.3blue1brown.com/lessons/fourier-series). From fec5cb0d8e8e74c8aecf991d05020aebd95f21c7 Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Sun, 2 Aug 2026 17:47:13 -0400 Subject: [PATCH 5/5] Add Fourier epicycles credits --- fourier-epicycles/credits.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 fourier-epicycles/credits.md diff --git a/fourier-epicycles/credits.md b/fourier-epicycles/credits.md new file mode 100644 index 0000000..d2e18a3 --- /dev/null +++ b/fourier-epicycles/credits.md @@ -0,0 +1 @@ +App made by [Joshua Kunst](https://jkunst.com) with ❤️ and ☕ using Shiny for R ✨. Code [here](https://github.com/jbkunst/visual-data-lab).