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 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) 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). 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). 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(); + } +})();