Random Walks Dashboard

Interactive dashboard with walker paths, step distributions, and fractal visualizations
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 1800

# ============================================================================
# FAST Package Loading (Pre-built webR binaries from GitHub Pages)
# ============================================================================
# Packages built by GitHub Actions and deployed to GitHub Pages
# See: https://github.com/JohnGavin/randomwalk/wiki/Shinylive-Lessons-Learned

cat("\n=== DASHBOARD SETUP ===\n")
cat("Installing packages from GitHub Pages...\n\n")

# Install randomwalk from GitHub Pages (built by deploy-pages.yaml workflow)
tryCatch({
  webr::install(
    "randomwalk",
    repos = c(
      "https://johngavin.github.io/randomwalk",  # GitHub Pages webR repo
      "https://repo.r-wasm.org"                   # Standard webR packages
    )
  )
  cat("✅ randomwalk installed\n")
}, error = function(e) {
  cat("❌ ERROR installing randomwalk:", e$message, "\n")
})

# Load shiny (usually pre-installed)
library(shiny)
cat("✅ shiny\n")

# Install ggplot2 dependencies and ggplot2
tryCatch({
  library(ggplot2)
  cat("✅ ggplot2\n")
}, error = function(e) {
  cat("⚠️  ggplot2 not found, installing dependencies...\n")
  # Install munsell first (required ggplot2 dependency often missing)
  webr::install("munsell", repos = "https://repo.r-wasm.org")
  cat("✅ munsell installed\n")
  webr::install("ggplot2", repos = "https://repo.r-wasm.org")
  library(ggplot2)
  cat("✅ ggplot2 installed and loaded\n")
})

# Load randomwalk
tryCatch({
  library(randomwalk)
  cat("✅ randomwalk (v", as.character(packageVersion("randomwalk")), ")\n", sep = "")
}, error = function(e) {
  cat("❌ ERROR loading randomwalk:", e$message, "\n")
  cat("Available packages:\n")
  print(installed.packages()[, c("Package", "Version")])
})

cat("\n⚠️  WebR Mode: Synchronous only (workers=0)\n")
cat("   Reason: mirai/nanonext not available in WebAssembly\n")
cat("   Note: Async works in native R with crew backend\n")

cat("\n=== SETUP COMPLETE ===\n\n")

# ============================================================================
# Helper Functions
# ============================================================================

# WebR environment detection (from Issue #129)
is_webr <- function() {
  exists(".webr_env", envir = .GlobalEnv) ||
    identical(Sys.getenv("WEBR"), "1")
}

# Format duration in seconds to mm:ss or hh:mm:ss format
format_duration <- function(seconds) {
  if (is.null(seconds) || is.na(seconds)) return("00:00")

  seconds <- round(seconds)
  if (seconds < 60) {
    return(sprintf("00:%02d", seconds))
  } else if (seconds < 3600) {
    mins <- floor(seconds / 60)
    secs <- seconds %% 60
    return(sprintf("%02d:%02d", mins, secs))
  } else {
    hours <- floor(seconds / 3600)
    mins <- floor((seconds %% 3600) / 60)
    secs <- seconds %% 60
    return(sprintf("%02d:%02d:%02d", hours, mins, secs))
  }
}

# ============================================================================
# Shiny UI
# ============================================================================

ui <- fluidPage(

  # Add CSS for collapsible panels and progress display
  tags$head(
    tags$style(HTML("
      .timer-display {
        font-size: 24px;
        font-weight: bold;
        color: #007bff;
        text-align: center;
        padding: 10px;
        background: #f0f8ff;
        border-radius: 5px;
        margin: 10px 0;
      }

      #run_sim:disabled {
        opacity: 0.6;
        cursor: not-allowed;
      }

      details {
        margin-bottom: 15px;
        border: 1px solid #ddd;
        border-radius: 4px;
        padding: 10px;
        background: #f8f9fa;
      }

      details[open] {
        background: white;
      }

      summary {
        font-weight: bold;
        cursor: pointer;
        padding: 5px;
        color: #333;
        user-select: none;
      }

      summary:hover {
        color: #007bff;
      }

      details > *:not(summary) {
        margin-top: 10px;
      }

      .progress-popup {
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        background: rgba(0, 0, 0, 0.8);
        color: white;
        padding: 20px;
        border-radius: 10px;
        z-index: 9999;
        font-size: 18px;
        min-width: 300px;
        text-align: center;
      }


      /* Dark theme for plots */
      .dark-plot-container {
        background-color: #2b2b2b;
        padding: 10px;
        border-radius: 5px;
      }
    "))
  ),

  # TOP SECTION: COLLAPSIBLE PARAMETER GROUPS
  fluidRow(
    column(12,
      # Use Shiny's wellPanel with conditional rendering for collapsible sections
      wellPanel(
        actionButton("toggle_sim_params", "▶ Simulation Parameters",
                    style = "background: none; border: none; font-weight: bold; font-size: 16px; padding: 0; margin-bottom: 10px;"),
        conditionalPanel(
          condition = "output.show_sim_params",
          fluidRow(
            column(4,
              h5("Grid Settings"),
              sliderInput("grid_size", "Grid Size:",
                          min = 20, max = 400, value = 100, step = 20),
              helpText("Size of the square grid (pixels per side)")
            ),
            column(8,
              h5("Walker Settings"),
              sliderInput("n_walkers", "Number of Walkers:",
                          min = 1, max = 1000, value = 200, step = 50),
              helpText(HTML("Number of walkers to simulate<br><em>Max: 70% of grid pixels (updates automatically)</em>")),

              sliderInput("max_steps", "Max Steps per Walker:",
                          min = 100, max = 10000, value = 5000, step = 100),
              helpText("Maximum steps before walker terminates")
            )
          )
        )
      ),

      wellPanel(
        actionButton("toggle_move_params", "▶ Movement Settings",
                    style = "background: none; border: none; font-weight: bold; font-size: 16px; padding: 0; margin-bottom: 10px;"),
        conditionalPanel(
          condition = "output.show_move_params",
          fluidRow(
            column(6,
              selectInput("neighborhood", "Neighborhood:",
                          choices = c("4-hood", "8-hood"),
                          selected = "4-hood"),
              helpText("4-hood: up/down/left/right, 8-hood: includes diagonals")
            ),
            column(6,
              selectInput("boundary", "Boundary Behavior:",
                          choices = c("terminate", "wrap"),
                          selected = "terminate"),
              helpText("terminate: stop at edges, wrap: toroidal topology")
            )
          )
        )
      ),

      wellPanel(
        actionButton("toggle_validation_params", "▶ Advanced Settings",
                    style = "background: none; border: none; font-weight: bold; font-size: 16px; padding: 0; margin-bottom: 10px;"),
        conditionalPanel(
          condition = "output.show_validation_params",
          fluidRow(
            column(4,
              selectInput("sync_mode", "Sync Mode:",
                         choices = c("static", "chunked"),
                         selected = "static"),
              helpText("static: Frozen snapshots (~5% collision). chunked: Batched updates (~15% collision)")
            ),
            column(4,
              checkboxInput("validate_strict", "Strict Validation",
                           value = FALSE),
              helpText("ON: Stop on isolated pixel. OFF: Warn and continue")
            ),
            column(4,
              sliderInput("validate_percent", "Validation %:",
                         min = 0, max = 20, value = 5, step = 1),
              helpText("Check every N% of walkers (0 = off)")
            )
          )
        )
      ),

      # Runtime Estimate and Run Button (always visible)
      wellPanel(
        style = "background-color: #f0f8ff; margin-top: 15px;",
        fluidRow(
          column(6,
            h5("Estimated Runtime:"),
            uiOutput("runtime_estimate")
          ),
          column(6,
            uiOutput("run_button_ui")
          )
        )
      )
    )
  ),

  hr(),

  # MAIN SECTION: FULL-WIDTH TABSET
  fluidRow(
    column(12,
      tabsetPanel(
        id = "tabs",

        # PAGE 1: Fractal Graph (Black Pixels)
        tabPanel("Fractal Graph",
                 h4("Grid Visualization - Black Pixels"),
                 plotOutput("fractal_plot", height = "600px"),
                 tags$p(style = "margin-top: 10px; margin-bottom: 10px; color: #666; font-style: italic; text-align: center;",
                        textOutput("fractal_caption", inline = TRUE)),
                 downloadButton("download_fractal", "Download Plot", class = "btn-sm", style = "margin-top: 10px;"),
                 hr(),
                 verbatimTextOutput("fractal_stats"),
                 hr(),
                 h5("Simulation Status:"),
                 verbatimTextOutput("status", placeholder = TRUE),
                 tags$div(
                   style = "background: #f0f0f0; padding: 10px; margin-top: 10px; font-family: monospace; font-size: 14px; border: 1px solid #ccc;",
                   h5("Live Progress:"),
                   verbatimTextOutput("progress_timer")
                 )),

        # PAGE 2: Walker Paths (First 25 and Last 25)
        tabPanel("Walker Paths",
                 h4("Walker Trajectories"),
                 tags$p(style = "color: #555; margin-bottom: 10px;",
                   "Shows walker trajectories from start (circle) to termination (square). ",
                   "Colors are stable - same walker keeps same color when adjusting slider."
                 ),
                 fluidRow(
                   column(6,
                     sliderInput("first_n_paths", "First N to terminate:",
                                min = 0, max = 20, value = 5, step = 1)),
                   column(6,
                     sliderInput("last_n_paths", "Last N to terminate:",
                                min = 0, max = 20, value = 5, step = 1))
                 ),
                 helpText("Select from walkers that FOUND THE FRACTAL (black_neighbor). ",
                         "These start from random interior positions. ",
                         "Boundary walkers (short paths) are excluded."),
                 plotOutput("paths_plot_first25", height = "500px"),
                 tags$p(style = "margin-top: 10px; color: #666; font-style: italic; text-align: center;",
                        textOutput("paths_caption", inline = TRUE))),

        # PAGE 3: Distributions (Path Lengths by Termination Reason)
        tabPanel("Distributions",
                 h4("Path Length Distributions"),
                 plotOutput("dist_overall", height = "300px"),
                 h5("Overall Distribution"),
                 hr(),
                 plotOutput("dist_by_reason", height = "300px"),
                 h5("By Termination Reason")),

        # PAGE 4: Statistics - Quartile Analysis
        tabPanel("Statistics",
                 h4("Quartile-Based Statistics"),
                 tags$div(
                   style = "background-color: #f5f5f5; padding: 10px; margin-bottom: 15px; border-radius: 5px;",
                   tags$p(strong("Terminology:")),
                   tags$ul(
                     tags$li(strong("Launch sequence (ID):"), " Order walkers were created (1, 2, ..., N)"),
                     tags$li(strong("Termination sequence:"), " Order walkers died (first to hit black = #1)"),
                     tags$li(strong("Path length (steps):"), " How many steps a walker took before terminating")
                   ),
                   tags$p("See ", tags$a(href = "#", onclick = "Shiny.setInputValue('tabs', 'Notes');", "Notes tab"),
                          " for details on memory-efficient quartile calculation.")
                 ),
                 helpText("Path length distributions partitioned by termination order quartiles (Q1=earliest terminators, Q4=latest)"),
                 hr(),
                 h5("Path Length by Termination Quartile"),
                 plotOutput("stats_quartile_boxplot", height = "350px"),
                 hr(),
                 h5("Histogram Panels by Quartile"),
                 plotOutput("stats_quartile_histograms", height = "400px"),
                 hr(),
                 h5("Distribution Shape Comparison"),
                 fluidRow(
                   column(6, plotOutput("stats_violin", height = "300px")),
                   column(6, plotOutput("stats_trend", height = "300px"))
                 ),
                 hr(),
                 h5("Summary Statistics by Quartile"),
                 verbatimTextOutput("stats_quartile_summary")),

        # PAGE 5: Survival Curve
        tabPanel("Survival Curve",
                 h4("Walker Survival Analysis"),

                 # Interactive slider for step threshold
                 sliderInput("survival_step", "Show survival at step:",
                             min = 0, max = 1000, value = 500, step = 50),
                 helpText("Drag to see what proportion of walkers survived to this step"),

                 # Main survival curve plot
                 plotOutput("survival_curve", height = "400px"),

                 hr(),

                 # Competing risks breakdown
                 h5("Termination by Reason"),
                 plotOutput("termination_breakdown", height = "250px"),

                 hr(),

                 # Summary statistics
                 verbatimTextOutput("survival_stats"),

                 hr(),

                 # Hypothesis Testing Section
                 h4("Hypothesis Test: Survival Decay"),
                 tags$p("Tests whether later walkers terminate faster as black pixels accumulate."),

                 # Hypothesis plot
                 plotOutput("hypothesis_plot", height = "350px"),

                 # Hypothesis statistics
                 verbatimTextOutput("hypothesis_stats")),

        # PAGE 6: Debug Info (renumbered from PAGE 5)
        tabPanel("Debug",
                 h4("Debug Information"),
                 h5("Package Versions"),
                 verbatimTextOutput("debug_versions"),
                 hr(),
                 h5("Current Inputs"),
                 verbatimTextOutput("debug_inputs"),
                 hr(),
                 h5("Backend Information"),
                 verbatimTextOutput("debug_backend"),
                 hr(),
                 h5("Periodic Updates"),
                 verbatimTextOutput("debug_periodic"),
                 hr(),
                 h5("Simulation Status:"),
                 verbatimTextOutput("status_debug", placeholder = TRUE),
                 tags$div(
                   style = "background: #f0f0f0; padding: 10px; margin-top: 10px; font-family: monospace; font-size: 14px; border: 1px solid #ccc;",
                   h5("Live Progress:"),
                   verbatimTextOutput("progress_timer_debug")
                 )),

        # PAGE 7: Notes
        tabPanel("Notes",
                 h4("About This Dashboard"),
                 tags$p("Interactive random walk simulation via WebR/Shinylive."),
                 hr(),

                 h5("Sequence Terminology"),
                 tags$div(
                   style = "background-color: #f0f8ff; padding: 12px; border-radius: 5px; margin-bottom: 15px;",
                   tags$p(strong("Launch Sequence (Walker ID):"), " Order walkers are created (1, 2, ..., N)"),
                   tags$p(strong("Termination Sequence:"), " Order walkers stop moving (first to die = order 1)"),
                   tags$p(style = "font-style: italic; color: #666;",
                          "These often differ! Walker ID 10 might terminate last (order 1000).")
                 ),

                 tags$table(style = "width: 100%; border-collapse: collapse; margin-bottom: 15px;",
                   tags$thead(
                     tags$tr(style = "background-color: #e8e8e8;",
                       tags$th(style = "padding: 8px; border: 1px solid #ddd;", "Sequence"),
                       tags$th(style = "padding: 8px; border: 1px solid #ddd;", "Pros"),
                       tags$th(style = "padding: 8px; border: 1px solid #ddd;", "Cons"),
                       tags$th(style = "padding: 8px; border: 1px solid #ddd;", "Best For")
                     )
                   ),
                   tags$tbody(
                     tags$tr(
                       tags$td(style = "padding: 8px; border: 1px solid #ddd;", "Launch (ID)"),
                       tags$td(style = "padding: 8px; border: 1px solid #ddd;", "Known at creation; memory-efficient"),
                       tags$td(style = "padding: 8px; border: 1px solid #ddd;", "Arbitrary ordering"),
                       tags$td(style = "padding: 8px; border: 1px solid #ddd;", "Memory-constrained sims")
                     ),
                     tags$tr(
                       tags$td(style = "padding: 8px; border: 1px solid #ddd;", "Termination"),
                       tags$td(style = "padding: 8px; border: 1px solid #ddd;", "Meaningful; reveals patterns"),
                       tags$td(style = "padding: 8px; border: 1px solid #ddd;", "Unknown until end"),
                       tags$td(style = "padding: 8px; border: 1px solid #ddd;", "Statistical analysis")
                     )
                   )
                 ),
                 hr(),

                 h5("Memory-Efficient Quartile Calculation"),
                 tags$div(
                   style = "background-color: #f5f5f5; padding: 12px; border-radius: 5px; margin-bottom: 15px;",
                   tags$p(strong("Key Insight:"), " Quartiles do NOT require full paths!"),
                   tags$p("Each walker stores only: ", tags$code("id, steps, termination_reason, termination_order")),
                   tags$pre(style = "background-color: #fff; padding: 8px; border-radius: 3px; font-size: 12px;",
                     "df$quartile <- cut(df$termination_order,\n  breaks = quantile(df$termination_order, probs = c(0, 0.25, 0.5, 0.75, 1)),\n  labels = c(\"Q1\", \"Q2\", \"Q3\", \"Q4\"),\n  include.lowest = TRUE)"
                   ),
                   tags$p(style = "font-size: 12px; color: #666;",
                          "Uses O(4 bytes) per walker, not O(thousands) for paths.")
                 ),
                 hr(),

                 h5("Path Storage Strategy"),
                 tags$p(strong("Current Approach (Launch ID):"), " Store paths for first/last 2000 walkers BY LAUNCH ID."),
                 tags$p("This is memory-efficient but means path visualization shows walkers by creation order, not termination order."),
                 tags$p(style = "color: #666; font-style: italic;",
                        "Future: Option E (chunked disk storage) would stream paths to parquet files, enabling retrieval by termination order. ",
                        "Not available in WebR due to arrow/parquet limitations in WASM."),
                 hr(),

                 h5("Execution Modes"),
                 tags$p("This browser dashboard runs in ", strong("sync sequential mode"), " (workers=0)."),
                 tags$p("For async parallel execution (workers > 0), use native R with crew/nanonext."),
                 hr(),

                 h5("Sync Mode Options (Advanced Settings)"),
                 tags$ul(
                   tags$li(strong("static:"), " Walkers use frozen grid snapshots. Lower collision rate (~5%). Faster but less realistic."),
                   tags$li(strong("chunked:"), " Grid updated in batches. Higher collision rate (~15%). More realistic pattern.")
                 ),
                 tags$p(tags$em("Tip: Use 'chunked' for more interesting fractal patterns.")),
                 hr(),

                 h5("Validation Options (Advanced Settings)"),
                 tags$ul(
                   tags$li(strong("Strict Validation:"), " Stop immediately if isolated pixel detected."),
                   tags$li(strong("Validation %:"), " Check for isolated pixels every N% of walkers.")
                 ),
                 hr(),

                 h5("Browser Limitations"),
                 tags$ul(
                   tags$li("Single-threaded execution (~400 steps/sec)"),
                   tags$li("First load: 15-30s (WebR package downloads)"),
                   tags$li("Recommended: grid ≤100, walkers ≤500, steps ≤1000"),
                   tags$li("For larger simulations, use native R with crew")
                 ),
                 hr(),

                 tags$a(href="https://johngavin.github.io/randomwalk/", "Documentation"),
                 tags$span(" | "),
                 tags$a(href="https://github.com/JohnGavin/randomwalk", "GitHub"))
      ) # End of tabs
    ) # End of column
  ) # End of fluidRow
)

# ============================================================================
# Shiny Server
# ============================================================================

server <- function(input, output, session) {

  # Dynamic walker constraint: Limit to 70% of grid pixels
  observe({
    max_walkers <- floor(0.7 * input$grid_size^2)
    updateSliderInput(
      session,
      "n_walkers",
      max = max_walkers,
      value = min(input$n_walkers, max_walkers)
    )
  })

  # Reactive values for simulation state and history
  sim_result <- reactiveVal(NULL)
  sim_count <- reactiveVal(0)  # Count total simulations run
  sim_state <- reactiveVal("idle")  # States: idle, running, complete, error
  sim_start_time <- reactiveVal(NULL)
  sim_end_time <- reactiveVal(NULL)
  sim_current_step <- reactiveVal(0)  # Track current simulation step
  sim_completed_walkers <- reactiveVal(0)  # Track completed walkers
  sim_black_pixels <- reactiveVal(0)  # Track black pixels

  # Reactive values for collapsible panels
  show_sim_params <- reactiveVal(FALSE)
  show_move_params <- reactiveVal(FALSE)
  show_validation_params <- reactiveVal(FALSE)

  # Toggle simulation parameters panel
  observeEvent(input$toggle_sim_params, {
    current <- show_sim_params()
    show_sim_params(!current)
    # Update button text
    updateActionButton(session, "toggle_sim_params",
                      label = ifelse(!current, "▼ Simulation Parameters", "▶ Simulation Parameters"))
  })

  # Toggle movement settings panel
  observeEvent(input$toggle_move_params, {
    current <- show_move_params()
    show_move_params(!current)
    # Update button text
    updateActionButton(session, "toggle_move_params",
                      label = ifelse(!current, "▼ Movement Settings", "▶ Movement Settings"))
  })

  # Toggle validation settings panel
  observeEvent(input$toggle_validation_params, {
    current <- show_validation_params()
    show_validation_params(!current)
    # Update button text
    updateActionButton(session, "toggle_validation_params",
                      label = ifelse(!current, "▼ Validation Settings", "▶ Validation Settings"))
  })

  # Output for conditional panels
  output$show_sim_params <- reactive({ show_sim_params() })
  output$show_move_params <- reactive({ show_move_params() })
  output$show_validation_params <- reactive({ show_validation_params() })
  outputOptions(output, "show_sim_params", suspendWhenHidden = FALSE)
  outputOptions(output, "show_move_params", suspendWhenHidden = FALSE)
  outputOptions(output, "show_validation_params", suspendWhenHidden = FALSE)

  # Runtime estimate (WebR performance varies: ~50-500 steps/sec depending on grid size & walker count)
  output$runtime_estimate <- renderUI({
    # Runtime calculation considers BOTH factors:
    # 1. Simulation steps = max_steps (all walkers move simultaneously each step)
    # 2. Per-step cost increases with more walkers due to collision checks & neighbor lookups
    #
    # Total cost = max_steps × (base_per_step_cost + walker_scaling × n_walkers)
    # Rearranged: effective_steps_per_sec = base_steps_per_sec / (1 + walker_scaling × n_walkers)

    grid_area <- input$grid_size^2

    # Empirical base performance model (1 walker) based on grid size:
    # Small grids (≤100x100): ~300 steps/sec
    # Medium grids (100-200): ~150 steps/sec
    # Large grids (>200): ~75 steps/sec
    if (grid_area <= 10000) {
      base_steps_per_sec <- 300
    } else if (grid_area <= 40000) {
      base_steps_per_sec <- 150
    } else {
      base_steps_per_sec <- 75
    }

    # Walker scaling factor: each walker adds ~0.2% overhead per step
    # (collision checks, neighbor lookups, state updates scale with active walkers)
    walker_scaling <- 0.002

    # Effective throughput accounting for both factors
    effective_steps_per_sec <- base_steps_per_sec / (1 + walker_scaling * input$n_walkers)

    # Total simulation time
    total_steps <- input$max_steps
    est_seconds <- total_steps / effective_steps_per_sec

    if (est_seconds < 60) {
      est_text <- paste0("~", format_duration(est_seconds))
      color <- "green"
    } else if (est_seconds < 300) {
      est_text <- paste0("~", format_duration(est_seconds))
      color <- "orange"
    } else {
      est_text <- paste0("~", format_duration(est_seconds), " (consider smaller params)")
      color <- "red"
    }

    # Calculate total walker steps for reference
    total_walker_steps <- input$n_walkers * input$max_steps

    tags$div(
      style = paste0("color: ", color, "; font-weight: bold;"),
      est_text,
      tags$br(),
      tags$small(style = "color: gray;",
                 sprintf("Max steps: %s | Walkers: %s\n(%sK total walker-steps, ~%.0f steps/sec effective)",
                         format(input$max_steps, big.mark=","),
                         format(input$n_walkers, big.mark=","),
                         format(round(total_walker_steps/1000), big.mark=","),
                         effective_steps_per_sec))
    )
  })

  # Reactive timer for progress updates (following shiny-examples/142-reactive-timer pattern)
  # This timer continues running even during synchronous simulation
  autoInvalidate <- reactiveTimer(100)  # Update every 100ms for smooth progress

  # Observe timer to update estimated progress during simulation
  observe({
    autoInvalidate()  # This triggers every 100ms

    if (sim_state() == "running" && !is.null(sim_start_time())) {
      elapsed <- as.numeric(difftime(Sys.time(), sim_start_time(), units = "secs"))

      # Estimate progress based on typical runtime
      est_steps_per_sec <- 200 / (1 + 0.002 * input$n_walkers)  # Adjust for walker count
      est_runtime <- input$max_steps / est_steps_per_sec
      progress <- min(elapsed / est_runtime, 0.99)

      # Update estimated counts for display
      sim_current_step(floor(input$max_steps * progress))
      sim_completed_walkers(floor(input$n_walkers * progress * 0.8))
      sim_black_pixels(floor(input$n_walkers * progress * 0.7))

      # Update status display with current progress
      output$status <- renderText({
        paste0(
          "SIMULATION RUNNING...\n",
          "Run #", sim_count(), "\n",
          "━━━━━━━━━━━━━━━━━━━━━━\n",
          "Started:  ", format(sim_start_time(), "%H:%M:%S"), "\n",
          "Elapsed:  ", format_duration(elapsed), "\n",
          "Progress: ", sprintf("%d%%", round(progress * 100)), "\n",
          "━━━━━━━━━━━━━━━━━━━━━━\n",
          "Grid: ", input$grid_size, "×", input$grid_size, "\n",
          "Walkers: ", sim_completed_walkers(), "/", input$n_walkers, "\n",
          "Black Pixels: ", sim_black_pixels(), "\n",
          "Workers: 0 (sync sequential)"
        )
      })
    }
  })

  # Real-time progress timer (following https://shinylive.io/r/examples/#timer pattern)
  # This WILL work in Shinylive/WebR - reactive timer continues during computation
  output$progress_timer <- renderText({
    autoInvalidate()  # Use the reactive timer that updates every 100ms

    if (sim_state() == "running") {
      # Get current time
      current_time <- Sys.time()
      elapsed <- if (!is.null(sim_start_time())) {
        round(as.numeric(difftime(current_time, sim_start_time(), units = "secs")), 1)
      } else {
        0
      }

      # Format with current counts from reactive values
      sprintf("⏱️ Time: %s | Walkers: %d/%d | Black Pixels: %d | Step: %d/%d",
              format_duration(elapsed),
              sim_completed_walkers(),
              input$n_walkers,
              sim_black_pixels(),
              sim_current_step(),
              input$max_steps)
    } else if (sim_state() == "complete") {
      result <- sim_result()
      if (!is.null(result)) {
        sprintf("✓ Complete | Black Pixels: %d | Grid: %d%%",
                result$statistics$black_pixels,
                round(result$statistics$black_percentage))
      } else {
        ""
      }
    } else {
      # Show current time to prove timer is working
      sprintf("Ready | Current time: %s", format(Sys.time(), "%H:%M:%S"))
    }
  })

  # Duplicate outputs for Debug tab (Shiny requires unique output IDs)
  output$status_debug <- renderText({
    # Mirror the status output
    if (sim_state() == "running") {
      paste0(
        "SIMULATION RUNNING...\n",
        "Run #", sim_count(), "\n",
        "Started: ", format(sim_start_time(), "%H:%M:%S")
      )
    } else if (sim_state() == "complete" && !is.null(sim_result())) {
      result <- sim_result()
      paste0(
        "SIMULATION COMPLETE\n",
        "Run #", sim_count(), "\n",
        "Black pixels: ", result$statistics$black_pixels, "\n",
        "Elapsed: ", format_duration(result$statistics$elapsed_time_secs)
      )
    } else if (sim_state() == "error") {
      "SIMULATION ERROR"
    } else {
      "Ready to run simulation..."
    }
  })

  output$progress_timer_debug <- renderText({
    autoInvalidate()  # Use the reactive timer
    if (sim_state() == "running") {
      elapsed <- if (!is.null(sim_start_time())) {
        round(as.numeric(difftime(Sys.time(), sim_start_time(), units = "secs")), 1)
      } else { 0 }
      sprintf("⏱️ Time: %s | Walkers: %d/%d | Black: %d",
              format_duration(elapsed),
              sim_completed_walkers(), input$n_walkers,
              sim_black_pixels())
    } else if (sim_state() == "complete" && !is.null(sim_result())) {
      result <- sim_result()
      sprintf("✓ Complete | Black: %d | Grid: %d%%",
              result$statistics$black_pixels,
              round(result$statistics$black_percentage))
    } else {
      sprintf("Ready | %s", format(Sys.time(), "%H:%M:%S"))
    }
  })

  # Render button with walker/pixel counts
  output$run_button_ui <- renderUI({
    if (sim_state() == "running") {
      # During simulation, show real-time walker/pixel counts
      invalidateLater(200)  # Update every 200ms

      elapsed <- if (!is.null(sim_start_time())) {
        as.numeric(difftime(Sys.time(), sim_start_time(), units = "secs"))
      } else {
        0
      }

      actionButton("run_sim_disabled",
                   sprintf("Running... W: %d/%d | B: %d | %s",
                          sim_completed_walkers(), input$n_walkers,
                          sim_black_pixels(), format_duration(elapsed)),
                   class = "btn-warning btn-lg",
                   style = "width: 100%; font-size: 14px;",
                   disabled = TRUE)
    } else if (sim_state() == "complete" && !is.null(sim_result())) {
      # Show walker count and black pixel count when complete
      result <- sim_result()
      total_secs <- as.numeric(difftime(sim_end_time(), sim_start_time(), units = "secs"))
      mins <- floor(total_secs / 60)
      secs <- floor(total_secs %% 60)

      # Get walker and black pixel counts
      walker_count <- result$statistics$completed_walkers
      black_count <- result$statistics$black_pixels

      actionButton("run_sim", sprintf("Run Simulation (Walkers: %d. Black: %d. Time: %02d:%02d)",
                                     walker_count, black_count, mins, secs),
                   class = "btn-success btn-lg",
                   style = "width: 100%;")
    } else if (sim_state() == "error") {
      actionButton("run_sim", "Run Simulation (Error - Try Again)",
                   class = "btn-danger btn-lg",
                   style = "width: 100%;")
    } else {
      actionButton("run_sim", "Run Simulation",
                   class = "btn-primary btn-lg",
                   style = "width: 100%;")
    }
  })


  # Run simulation when button clicked
  observeEvent(input$run_sim, {
    # Initialize state
    sim_state("running")
    sim_start_time(Sys.time())
    sim_count(sim_count() + 1)

    output$status <- renderText({
      paste0(
        "SIMULATION RUNNING...\n",
        "Run #", sim_count(), "\n",
        "Grid: ", input$grid_size, "×", input$grid_size, "\n",
        "Walkers: ", input$n_walkers, "\n",
        "Workers: 0 (sync sequential)\n",
        "Started: ", format(sim_start_time(), "%H:%M:%S"), "\n",
        "Status: Processing..."
      )
    })

    # Validate walker count
    max_allowed <- floor(0.7 * input$grid_size^2)
    if (input$n_walkers > max_allowed) {
      sim_state("error")
      output$status <- renderText({
        sprintf("ERROR: Too many walkers (%d) for grid size %dx%d.\nMaximum allowed: %d (70%% of grid pixels)",
                input$n_walkers, input$grid_size, input$grid_size, max_allowed)
      })
      return()
    }

    # Reset progress tracking
    sim_current_step(0)
    sim_completed_walkers(0)
    sim_black_pixels(0)

    # Run the simulation
    tryCatch({
      result <- run_simulation(
        grid_size = input$grid_size,
        n_walkers = input$n_walkers,
        neighborhood = input$neighborhood,
        boundary = input$boundary,
        max_steps = input$max_steps,
        workers = 0,
        sync_mode = input$sync_mode,
        verbose = FALSE,
        validate_percent = input$validate_percent,
        validate_strict = input$validate_strict
      )

      # Store result
      sim_result(result)
      sim_completed_walkers(result$statistics$completed_walkers)
      sim_black_pixels(result$statistics$black_pixels)
      sim_current_step(input$max_steps)

      # Mark complete
      sim_state("complete")
      sim_end_time(Sys.time())

      # Calculate elapsed time
      elapsed <- as.numeric(difftime(sim_end_time(), sim_start_time(), units = "secs"))

      # Re-enable the button
      updateActionButton(session, "run_sim",
                        label = "Run Simulation",
                        disabled = FALSE)

      # Browser console logging for WebR diagnostics
      message(sprintf("\n=== SIMULATION COMPLETE ==="))
      message(sprintf("Grid size: %dx%d", input$grid_size, input$grid_size))
      message(sprintf("Walkers: %d", input$n_walkers))
      message(sprintf("Black pixels: %d", sum(result$grid == 1)))
      message(sprintf("Elapsed time: %s", format_duration(elapsed)))
      message(sprintf("==========================\n"))

      output$status <- renderText({
        paste0(
          "SIMULATION COMPLETE\n",
          "Run #", sim_count(), "\n",
          "━━━━━━━━━━━━━━━━━━━━━━\n",
          "Started:  ", format(sim_start_time(), "%H:%M:%S"), "\n",
          "Finished: ", format(sim_end_time(), "%H:%M:%S"), "\n",
          "Elapsed:  ", format_duration(elapsed), "\n",
          "━━━━━━━━━━━━━━━━━━━━━━\n",
          "Backend: synchronous (WebR browser)\n",
          "Black Pixels: ", result$statistics$black_pixels, "\n",
          "Walkers: ", result$statistics$completed_walkers, " completed"
        )
      })

    }, error = function(e) {
      sim_state("error")
      sim_end_time(Sys.time())

      # Re-enable the button
      updateActionButton(session, "run_sim",
                        label = "Run Simulation",
                        disabled = FALSE)

      output$status <- renderText({
        paste0(
          "SIMULATION ERROR\n",
          "━━━━━━━━━━━━━━━━━━━━━━\n",
          "Error: ", conditionMessage(e), "\n",
          "━━━━━━━━━━━━━━━━━━━━━━\n",
          "Please try again with different parameters."
        )
      })
    })
  })


  # PAGE 1: Fractal Graph with arrival time coloring
  output$fractal_plot <- renderPlot({
    # If simulation is running, show progress in plot
    if (sim_state() == "running") {
      invalidateLater(300)  # Update every 300ms

      # Create a simple progress visualization
      par(bg = "gray70", mar = c(5, 4, 4, 2))
      plot(1, type = "n", xlim = c(0, 1), ylim = c(0, 1),
           xlab = "", ylab = "", axes = FALSE,
           main = "Simulation Running...")

      # Show progress as text in plot
      text(0.5, 0.7, sprintf("Walkers: %d / %d",
                            sim_completed_walkers(), input$n_walkers),
           cex = 2, font = 2)
      text(0.5, 0.5, sprintf("Black pixels: %d",
                            sim_black_pixels()),
           cex = 2, font = 2)

      elapsed <- if (!is.null(sim_start_time())) {
        as.numeric(difftime(Sys.time(), sim_start_time(), units = "secs"))
      } else { 0 }
      text(0.5, 0.3, sprintf("Time: %s", format_duration(elapsed)),
           cex = 1.5)
      return()
    }

    req(sim_result())
    result <- sim_result()

    # Defensive checks for WebR environment (Issue #157)
    req(!is.null(result$grid))
    req(nrow(result$grid) > 0)
    req(sum(result$grid == 1) > 0)  # Ensure has black pixels

    # Use enhanced plot with color-coded arrival times
    p <- plot_grid_enhanced(result,
                           quantiles = 5,
                           color_scheme = "viridis")

    # Store the plot for caption extraction
    # The plot_grid_enhanced function returns plot with caption as attribute
    p
  })

  # Display the caption separately
  output$fractal_caption <- renderText({
    req(sim_result())
    result <- sim_result()

    # Generate the plot to get the caption
    p <- plot_grid_enhanced(result,
                           quantiles = 5,
                           color_scheme = "viridis")

    # Extract and return the caption
    caption <- attr(p, "caption")
    if (!is.null(caption) && caption != "") {
      caption
    } else {
      ""
    }
  })

  output$fractal_stats <- renderText({
    req(sim_result())
    stats <- sim_result()$statistics
    paste0(
      "Black Pixels: ", stats$black_pixels,
      " (", sprintf("%d%%", round(stats$black_percentage)), " of grid)\n",
      "Grid Size: ", stats$grid_size, "×", stats$grid_size,
      " (", stats$grid_size^2, " total cells)"
    )
  })

  # Download handler for fractal plot
  output$download_fractal <- downloadHandler(
    filename = function() {
      paste0("fractal_dla_", format(Sys.time(), "%Y%m%d_%H%M%S"), ".png")
    },
    content = function(file) {
      req(sim_result())
      result <- sim_result()
      p <- plot_grid_enhanced(result, quantiles = 5, color_scheme = "viridis")
      ggplot2::ggsave(file, plot = p, width = 8, height = 8, dpi = 150)
    }
  )

  # PAGE 2: Walker Paths
  output$paths_plot_first25 <- renderPlot({
    req(sim_result())
    result <- sim_result()
    n_walkers <- length(result$walkers)

    if (n_walkers >= 1) {
      first_n <- min(input$first_n_paths, n_walkers)
      last_n <- min(input$last_n_paths, n_walkers)

      # CRITICAL FIX: Only select from walkers that HAVE paths stored
      # Paths are only stored for BLACK_NEIGHBOR terminators (walkers that found the fractal)
      # Boundary walkers terminate quickly but don't have paths - skip them

      # Filter to walkers with paths first
      walkers_with_paths <- Filter(function(w) {
        !is.null(w$path) && length(w$path) > 0
      }, result$walkers)

      n_with_paths <- length(walkers_with_paths)

      if (n_with_paths == 0) {
        # No paths stored - show message
        plot.new()
        text(0.5, 0.5, "No walker paths available.\nPaths are stored for walkers that found the fractal.",
             cex = 1.2, col = "red")
        return()
      }

      # Sort by termination order (among walkers with paths)
      walkers_ordered <- walkers_with_paths[order(sapply(walkers_with_paths, function(w) {
        if (!is.null(w$termination_order)) w$termination_order else w$id
      }))]

      # Select first N and last N from walkers WITH PATHS
      first_n <- min(first_n, n_with_paths)
      last_n <- min(last_n, n_with_paths)

      walker_indices <- unique(c(
        if(first_n > 0) 1:first_n else NULL,
        if(last_n > 0) (n_with_paths - last_n + 1):n_with_paths else NULL
      ))

      if (length(walker_indices) > 0) {
        # Plot only selected walkers' paths
        filtered_walkers <- walkers_ordered[walker_indices]

        # Create custom plot with better visibility
        grid_size <- result$parameters$grid_size

        # Create plot with gray background for contrast
        par(bg = "gray70")
        plot(1, type = "n", xlim = c(1, grid_size), ylim = c(1, grid_size),
             xlab = "", ylab = "",
             main = paste(length(walker_indices), "of", n_with_paths, "Fractal-Finding Walkers (by termination order)"),
             asp = 1, col.main = "black", col.lab = "black", col.axis = "black")

        # Add subtle grid for visibility
        grid(nx = grid_size, ny = grid_size, col = "gray70", lty = 1, lwd = 0.3)

        # Create STABLE color mapping based on termination order (not position)
        distinct_colors <- c("red", "blue", "darkgreen", "purple", "orange",
                           "brown", "deeppink", "darkcyan", "darkmagenta", "darkblue",
                           "darkred", "forestgreen", "black", "goldenrod", "darkviolet")

        # Map each walker to a consistent color based on their termination order
        # This ensures walker #20 always gets the same color regardless of selection
        color_map <- list()
        for (i in seq_along(walkers_ordered)) {
          term_order <- walkers_ordered[[i]]$termination_order
          # Use modulo to cycle through colors if more walkers than colors
          color_index <- ((term_order - 1) %% length(distinct_colors)) + 1
          color_map[[as.character(term_order)]] <- distinct_colors[color_index]
        }

        # Collect all paths with STABLE colors
        all_paths <- list()
        all_starts <- list()
        all_ends <- list()

        for (i in seq_along(filtered_walkers)) {
          walker <- filtered_walkers[[i]]
          # Get stable color for this walker based on termination order
          walker_color <- color_map[[as.character(walker$termination_order)]]

          # Check if path exists and is not empty
          if (!is.null(walker$path) && length(walker$path) > 0) {
            path_matrix <- do.call(rbind, walker$path)
            # Use walker termination order as key to ensure uniqueness
            key <- paste0("walker_", walker$termination_order)

            all_paths[[key]] <- list(
              x = path_matrix[, 2],
              y = path_matrix[, 1],
              col = walker_color
            )
            all_starts[[key]] <- list(
              x = path_matrix[1, 2],
              y = path_matrix[1, 1],
              col = walker_color
            )
          }

          # Always mark end position
          if (!is.null(walker$pos)) {
            key <- paste0("walker_", walker$termination_order)
            all_ends[[key]] <- list(
              x = walker$pos[2],
              y = walker$pos[1],
              col = walker_color
            )
          }
        }

        # Draw all paths
        for (path_data in all_paths) {
          if (!is.null(path_data)) {
            points(path_data$x, path_data$y,
                   pch = 16, col = path_data$col, cex = 0.8)
          }
        }

        # Draw all start positions
        for (start_data in all_starts) {
          if (!is.null(start_data)) {
            points(start_data$x, start_data$y,
                   pch = 21, bg = start_data$col, col = "black", cex = 2, lwd = 2)
          }
        }

        # Draw all end positions
        for (end_data in all_ends) {
          if (!is.null(end_data)) {
            points(end_data$x, end_data$y,
                   pch = 22, bg = end_data$col, col = "black", cex = 2.5, lwd = 2)
          }
        }

        # Caption moved to separate textOutput below plot
      } else {
        plot.new()
        text(0.5, 0.5, "No walkers selected")
      }
    } else {
      plot.new()
      text(0.5, 0.5, "No walkers available")
    }
  })

  # Caption for Walker Paths plot
  output$paths_caption <- renderText({
    "Symbols: Circle = Start | Square = End | Dots = Path. Colors by termination order."
  })

  # PAGE 3: Distributions
  output$dist_overall <- renderPlot({
    req(sim_result())
    result <- sim_result()

    # Set gray background
    par(bg = "gray70")

    # Defensive check for walkers
    if (is.null(result$walkers) || length(result$walkers) == 0) {
      plot.new()
      text(0.5, 0.5, "Run a simulation first", cex = 1.5, col = "gray")
      return()
    }

    # Extract path lengths safely
    # NOTE: walker$path is a LIST of positions, not a matrix
    # Use walker$steps for accurate step count
    path_lengths <- tryCatch({
      sapply(result$walkers, function(w) {
        if (!is.null(w$steps)) {
          w$steps
        } else {
          NA
        }
      })
    }, error = function(e) {
      numeric(0)
    })

    # Remove NAs and ensure numeric type
    path_lengths <- as.numeric(path_lengths[!is.na(path_lengths)])

    if (length(path_lengths) == 0 || !is.numeric(path_lengths)) {
      plot.new()
      text(0.5, 0.5, "No valid path data available", cex = 1.5, col = "gray")
      return()
    }

    # Need at least 2 values for meaningful distribution
    if (length(path_lengths) < 2) {
      plot.new()
      text(0.5, 0.5, paste0("Only ", length(path_lengths), " path - need at least 2"),
           cex = 1.2, col = "gray")
      return()
    }

    # Plot histogram with error handling
    tryCatch({
      hist(path_lengths,
           breaks = min(30, max(5, length(unique(path_lengths)))),
           col = "steelblue",
           border = "white",
           main = "Overall Path Length Distribution",
           xlab = "Path Length (steps)",
           ylab = "Frequency")
      abline(v = median(path_lengths), col = "red", lwd = 2, lty = 2)
      legend("topright",
             legend = c(paste("Median:", median(path_lengths)),
                       paste("Mean:", sprintf("%.1f", mean(path_lengths)))),
             col = c("red", "black"),
             lty = c(2, 1),
             lwd = 2)
    }, error = function(e) {
      plot.new()
      text(0.5, 0.5, paste0("Error creating plot:\n", e$message), cex = 1, col = "red")
    })
  })

  output$dist_by_reason <- renderPlot({
    req(sim_result())
    result <- sim_result()

    # Defensive check for walkers
    if (is.null(result$walkers) || length(result$walkers) == 0) {
      plot.new()
      text(0.5, 0.5, "Run a simulation first", cex = 1.5, col = "gray")
      return()
    }

    # Extract termination reasons and path lengths safely
    # NOTE: walker$path is a LIST, not a matrix - use walker$steps
    path_lengths <- tryCatch({
      sapply(result$walkers, function(w) {
        if (!is.null(w$steps)) w$steps else NA
      })
    }, error = function(e) numeric(0))

    reasons <- tryCatch({
      sapply(result$walkers, function(w) {
        if (!is.null(w$termination_reason)) w$termination_reason else "unknown"
      })
    }, error = function(e) character(0))

    # Remove NAs and ensure same length
    valid_idx <- !is.na(path_lengths)
    path_lengths <- as.numeric(path_lengths[valid_idx])
    reasons <- as.character(reasons[valid_idx])

    # Defensive check: ensure vectors are same length
    min_len <- min(length(path_lengths), length(reasons))
    if (min_len < length(path_lengths) || min_len < length(reasons)) {
      path_lengths <- path_lengths[1:min_len]
      reasons <- reasons[1:min_len]
    }

    # Check if we have valid data
    if (length(path_lengths) == 0 || length(reasons) == 0 ||
        !is.numeric(path_lengths) || !is.character(reasons)) {
      plot.new()
      text(0.5, 0.5, "No valid termination data available", cex = 1.5, col = "gray")
      return()
    }

    # Need at least 2 data points
    if (length(path_lengths) < 2) {
      plot.new()
      text(0.5, 0.5, "Need at least 2 walkers for distribution", cex = 1.2, col = "gray")
      return()
    }

    # Create data frame with error handling
    walkers_data <- tryCatch({
      data.frame(
        path_length = path_lengths,
        reason = reasons,
        stringsAsFactors = FALSE
      )
    }, error = function(e) {
      NULL
    })

    if (is.null(walkers_data)) {
      plot.new()
      text(0.5, 0.5, "Error creating data frame", cex = 1.2, col = "red")
      return()
    }

    # Plot boxplot by termination reason with error handling
    tryCatch({
      print(
        ggplot(walkers_data, aes(x = reason, y = path_length, fill = reason)) +
          geom_boxplot(alpha = 0.7) +
          geom_jitter(width = 0.2, alpha = 0.3) +
          labs(title = "Path Lengths by Termination Reason",
               x = "Termination Reason",
               y = "Path Length (steps)") +
          theme_minimal() +
          theme(legend.position = "none",
                panel.background = element_rect(fill = "gray70"),
                plot.background = element_rect(fill = "gray70"))
      )
    }, error = function(e) {
      plot.new()
      text(0.5, 0.5, paste0("Error creating plot:\n", e$message), cex = 1, col = "red")
    })
  })

  # PAGE 4: Statistics - Quartile-based Analysis
  # Helper function to prepare quartile data
  get_quartile_data <- reactive({
    req(sim_result())
    result <- sim_result()

    # Extract steps and termination order
    steps <- sapply(result$walkers, function(w) w$steps)
    term_order <- sapply(result$walkers, function(w) w$termination_order)

    # Create data frame
    df <- data.frame(
      steps = steps,
      termination_order = term_order,
      stringsAsFactors = FALSE
    )

    # Assign quartiles based on termination order
    df$quartile <- cut(df$termination_order,
                       breaks = quantile(df$termination_order, probs = c(0, 0.25, 0.5, 0.75, 1)),
                       labels = c("Q1 (First 25%)", "Q2 (25-50%)", "Q3 (50-75%)", "Q4 (Last 25%)"),
                       include.lowest = TRUE)
    df
  })

  # Boxplot by quartile
  output$stats_quartile_boxplot <- renderPlot({
    req(sim_result())
    df <- get_quartile_data()

    if (is.null(df) || nrow(df) < 4) {
      plot.new()
      text(0.5, 0.5, "Need at least 4 walkers for quartile analysis", cex = 1.2, col = "gray")
      return()
    }

    tryCatch({
      print(
        ggplot(df, aes(x = quartile, y = steps)) +
          geom_boxplot(fill = "steelblue", alpha = 0.7, outlier.shape = 21) +
          geom_jitter(width = 0.15, alpha = 0.3, size = 1) +
          labs(
            title = "Path Length Distribution by Termination Quartile",
            subtitle = "Q1 = earliest terminators, Q4 = latest terminators",
            x = "Termination Quartile",
            y = "Path Length (steps)"
          ) +
          theme_minimal(base_size = 12) +
          theme(legend.position = "none",
                axis.text.x = element_text(angle = 15, hjust = 1),
                panel.background = element_rect(fill = "gray70"),
                plot.background = element_rect(fill = "gray70"))
      )
    }, error = function(e) {
      plot.new()
      text(0.5, 0.5, paste0("Error: ", e$message), cex = 1, col = "red")
    })
  })

  # Histogram panels by quartile
  output$stats_quartile_histograms <- renderPlot({
    req(sim_result())
    df <- get_quartile_data()

    if (is.null(df) || nrow(df) < 4) {
      plot.new()
      text(0.5, 0.5, "Need at least 4 walkers for quartile analysis", cex = 1.2, col = "gray")
      return()
    }

    tryCatch({
      print(
        ggplot(df, aes(x = steps)) +
          geom_histogram(bins = 20, fill = "steelblue", alpha = 0.8, color = "white") +
          facet_wrap(~ quartile, scales = "free_y", ncol = 2) +
          labs(
            title = "Path Length Histograms by Termination Quartile",
            x = "Path Length (steps)",
            y = "Count"
          ) +
          theme_minimal(base_size = 11) +
          theme(legend.position = "none",
                strip.text = element_text(face = "bold"),
                panel.background = element_rect(fill = "gray70"),
                plot.background = element_rect(fill = "gray70"))
      )
    }, error = function(e) {
      plot.new()
      text(0.5, 0.5, paste0("Error: ", e$message), cex = 1, col = "red")
    })
  })

  # Violin plot for distribution shape comparison
  output$stats_violin <- renderPlot({
    req(sim_result())
    df <- get_quartile_data()

    if (is.null(df) || nrow(df) < 4) {
      plot.new()
      text(0.5, 0.5, "Need at least 4 walkers", cex = 1.2, col = "gray")
      return()
    }

    tryCatch({
      print(
        ggplot(df, aes(x = quartile, y = steps)) +
          geom_violin(fill = "steelblue", alpha = 0.6, trim = FALSE) +
          geom_boxplot(width = 0.15, fill = "white", alpha = 0.8) +
          labs(
            title = "Distribution Shape by Quartile",
            x = "Termination Quartile",
            y = "Path Length (steps)"
          ) +
          theme_minimal(base_size = 11) +
          theme(axis.text.x = element_text(angle = 15, hjust = 1),
                panel.background = element_rect(fill = "gray70"),
                plot.background = element_rect(fill = "gray70"))
      )
    }, error = function(e) {
      plot.new()
      text(0.5, 0.5, paste0("Error: ", e$message), cex = 1, col = "red")
    })
  })

  # Trend plot showing mean/median across quartiles
  output$stats_trend <- renderPlot({
    req(sim_result())
    df <- get_quartile_data()

    if (is.null(df) || nrow(df) < 4) {
      plot.new()
      text(0.5, 0.5, "Need at least 4 walkers", cex = 1.2, col = "gray")
      return()
    }

    tryCatch({
      # Calculate summary statistics by quartile
      summary_df <- do.call(rbind, lapply(levels(df$quartile), function(q) {
        q_data <- df[df$quartile == q, ]
        data.frame(
          quartile = q,
          mean_steps = mean(q_data$steps),
          median_steps = median(q_data$steps),
          q_num = match(q, levels(df$quartile))
        )
      }))

      print(
        ggplot(summary_df, aes(x = q_num)) +
          geom_line(aes(y = mean_steps, color = "Mean"), linewidth = 1.2) +
          geom_point(aes(y = mean_steps, color = "Mean"), size = 3) +
          geom_line(aes(y = median_steps, color = "Median"), linewidth = 1.2, linetype = "dashed") +
          geom_point(aes(y = median_steps, color = "Median"), size = 3) +
          scale_x_continuous(breaks = 1:4, labels = c("Q1", "Q2", "Q3", "Q4")) +
          scale_color_manual(values = c("Mean" = "steelblue", "Median" = "darkred")) +
          labs(
            title = "Path Length Trend Across Quartiles",
            x = "Termination Quartile",
            y = "Path Length (steps)",
            color = "Statistic"
          ) +
          theme_minimal(base_size = 11) +
          theme(legend.position = "bottom",
                panel.background = element_rect(fill = "gray70"),
                plot.background = element_rect(fill = "gray70"))
      )
    }, error = function(e) {
      plot.new()
      text(0.5, 0.5, paste0("Error: ", e$message), cex = 1, col = "red")
    })
  })

  # Summary statistics by quartile
  output$stats_quartile_summary <- renderPrint({
    req(sim_result())
    df <- get_quartile_data()

    if (is.null(df) || nrow(df) < 4) {
      cat("Need at least 4 walkers for quartile analysis\n")
      return()
    }

    cat("=== QUARTILE SUMMARY STATISTICS ===\n\n")

    for (q in levels(df$quartile)) {
      q_data <- df[df$quartile == q, ]
      cat(sprintf("%s (n=%d):\n", q, nrow(q_data)))
      cat(sprintf("  Min:    %d steps\n", min(q_data$steps)))
      cat(sprintf("  Q1:     %d steps\n", as.integer(quantile(q_data$steps, 0.25))))
      cat(sprintf("  Median: %d steps\n", as.integer(median(q_data$steps))))
      cat(sprintf("  Q3:     %d steps\n", as.integer(quantile(q_data$steps, 0.75))))
      cat(sprintf("  Max:    %d steps\n", max(q_data$steps)))
      cat(sprintf("  Mean:   %.1f steps\n", mean(q_data$steps)))
      cat(sprintf("  SD:     %.1f steps\n", sd(q_data$steps)))
      cat("\n")
    }

    # Overall comparison
    cat("=== QUARTILE COMPARISON ===\n\n")
    means <- tapply(df$steps, df$quartile, mean)
    cat("Mean steps by quartile:\n")
    for (i in seq_along(means)) {
      cat(sprintf("  %s: %.1f\n", names(means)[i], means[i]))
    }

    # Trend indicator
    if (means[4] < means[1]) {
      cat("\n⬇️ DECREASING TREND: Later walkers survive fewer steps\n")
      cat(sprintf("   (Q4 mean %.0f%% of Q1 mean)\n", 100 * means[4] / means[1]))
    } else if (means[4] > means[1]) {
      cat("\n⬆️ INCREASING TREND: Later walkers survive more steps\n")
      cat(sprintf("   (Q4 mean %.0f%% of Q1 mean)\n", 100 * means[4] / means[1]))
    } else {
      cat("\n➡️ FLAT: No clear trend across quartiles\n")
    }
  })

  # ============================================================================
  # PAGE 5: Survival Curve - Server Logic
  # ============================================================================

  # Dynamic slider max based on actual max steps in data
  observe({
    req(sim_result())
    result <- sim_result()
    max_step <- max(sapply(result$walkers, function(w) w$steps))
    updateSliderInput(session, "survival_step",
                      max = max_step,
                      value = min(input$survival_step, max_step))
  })

  # Survival curve plot (Kaplan-Meier style)
  output$survival_curve <- renderPlot({
    req(sim_result())
    result <- sim_result()

    # Build survival data
    steps <- sapply(result$walkers, function(w) w$steps)
    n_total <- length(steps)

    # Calculate survival proportion at each unique step value
    unique_steps <- sort(unique(steps))
    survival_at_step <- sapply(unique_steps, function(s) {
      sum(steps > s) / n_total
    })

    # Current slider position
    current_step <- input$survival_step
    current_survival <- sum(steps > current_step) / n_total

    # Plot using ggplot2
    df <- data.frame(step = unique_steps, survival = survival_at_step)

    ggplot(df, aes(x = step, y = survival)) +
      geom_step(color = "steelblue", linewidth = 1.2) +
      geom_vline(xintercept = current_step, linetype = "dashed", color = "red") +
      geom_point(aes(x = current_step, y = current_survival),
                 color = "red", size = 3) +
      annotate("text", x = current_step, y = min(current_survival + 0.08, 1),
               label = sprintf("%.1f%% alive", current_survival * 100),
               color = "red", hjust = 0, size = 4) +
      labs(
        title = "Walker Survival Curve",
        subtitle = sprintf("Red line: step %d (%.1f%% still walking)",
                           current_step, current_survival * 100),
        x = "Steps",
        y = "Proportion Surviving"
      ) +
      scale_y_continuous(labels = scales::percent_format(), limits = c(0, 1)) +
      theme_minimal(base_size = 14) +
      theme(
        plot.title = element_text(hjust = 0.5, face = "bold"),
        plot.subtitle = element_text(hjust = 0.5, color = "gray40"),
        panel.background = element_rect(fill = "gray70"),
        plot.background = element_rect(fill = "gray70")
      )
  })

  # Termination breakdown by reason
  output$termination_breakdown <- renderPlot({
    req(sim_result())
    result <- sim_result()

    reasons <- sapply(result$walkers, function(w) w$termination_reason)
    reason_counts <- table(reasons)

    df <- data.frame(
      reason = names(reason_counts),
      count = as.integer(reason_counts)
    )
    df$pct <- df$count / sum(df$count) * 100

    # Color mapping for different termination reasons
    color_map <- c(
      "black_neighbor" = "steelblue",
      "hit_boundary" = "orange",
      "max_steps" = "gray50",
      "touched_black" = "darkblue"
    )

    # Create fill colors vector matching data
    df$fill_color <- ifelse(df$reason %in% names(color_map),
                            color_map[df$reason],
                            "gray70")

    ggplot(df, aes(x = reorder(reason, -count), y = count, fill = reason)) +
      geom_col() +
      geom_text(aes(label = sprintf("%.1f%%", pct)), vjust = -0.5, size = 3.5) +
      scale_fill_manual(values = color_map, na.value = "gray70") +
      labs(
        title = "How Walkers Terminated",
        x = "Termination Reason",
        y = "Count"
      ) +
      theme_minimal(base_size = 12) +
      theme(
        legend.position = "none",
        axis.text.x = element_text(angle = 30, hjust = 1),
        plot.title = element_text(hjust = 0.5, face = "bold"),
        panel.background = element_rect(fill = "gray70"),
        plot.background = element_rect(fill = "gray70")
      )
  })

  # Survival statistics text
  output$survival_stats <- renderPrint({
    req(sim_result())
    result <- sim_result()

    steps <- sapply(result$walkers, function(w) w$steps)

    cat("=== SURVIVAL STATISTICS ===\n\n")
    cat(sprintf("Total walkers: %d\n", length(steps)))
    cat(sprintf("Median survival: %d steps\n", median(steps)))
    cat(sprintf("Mean survival: %.1f steps\n", mean(steps)))
    cat(sprintf("25th percentile: %d steps\n", as.integer(quantile(steps, 0.25))))
    cat(sprintf("75th percentile: %d steps\n", as.integer(quantile(steps, 0.75))))
    cat("\n")
    cat("At current slider position:\n")
    current_survival <- sum(steps > input$survival_step)
    cat(sprintf("  %d walkers (%.1f%%) survived past step %d\n",
                current_survival,
                current_survival / length(steps) * 100,
                input$survival_step))
  })

  # Hypothesis test: Geometric vs Exponential decay models
  output$hypothesis_plot <- renderPlot({
    req(sim_result())
    result <- sim_result()

    # Build survival data with termination order
    survdat <- data.frame(
      order = sapply(result$walkers, function(w) {
        if (!is.null(w$termination_order)) w$termination_order else NA
      }),
      steps = sapply(result$walkers, function(w) w$steps)
    )

    # Remove NA orders (if termination_order not available)
    survdat <- survdat[!is.na(survdat$order), ]

    if (nrow(survdat) < 10) {
      # Not enough data for hypothesis testing
      plot.new()
      text(0.5, 0.5, "Need at least 10 walkers with termination order for hypothesis test",
           cex = 1.2, col = "gray")
      return()
    }

    survdat <- survdat[order(survdat$order), ]

    # H0: Geometric decay model
    # Geometric: P(survive n steps) = (1-p)^n, E[steps] = (1-p)/p
    # Fit p via method of moments: p_hat = 1 / (1 + mean(steps))
    geom_p <- 1 / (1 + mean(survdat$steps))
    # Predicted mean at each order position (linear decay in expected value)
    survdat$geom_pred <- (1 / geom_p) * (1 - survdat$order / max(survdat$order) * 0.5)

    # H1: Exponential decay model
    # E[steps | order] = a * exp(-lambda * order)
    survdat$log_steps <- log(survdat$steps + 1)
    exp_model <- lm(log_steps ~ order, data = survdat)
    survdat$exp_pred <- exp(fitted(exp_model))

    # Non-parametric: Kolmogorov-Smirnov tests
    geom_ks <- tryCatch({
      ks.test(survdat$steps, function(x) pgeom(x, prob = geom_p))
    }, error = function(e) list(p.value = 0, statistic = 1))

    exp_rate <- 1 / mean(survdat$steps)
    exp_ks <- tryCatch({
      ks.test(survdat$steps, function(x) pexp(x, rate = exp_rate))
    }, error = function(e) list(p.value = 0, statistic = 1))

    # Determine which model fits better (higher p-value = better fit)
    geom_better <- geom_ks$p.value > exp_ks$p.value

    # Plot
    ggplot(survdat, aes(x = order, y = steps)) +
      geom_point(alpha = 0.4, size = 1.5, color = "gray40") +
      geom_line(aes(y = geom_pred), color = "blue", linewidth = 1.2, linetype = "dashed") +
      geom_line(aes(y = exp_pred), color = "red", linewidth = 1.2) +
      annotate("text", x = max(survdat$order) * 0.6, y = max(survdat$steps) * 0.9,
               label = sprintf("H0 Geometric: KS p=%.3f\nH1 Exponential: KS p=%.3f\nBetter fit: %s",
                               geom_ks$p.value, exp_ks$p.value,
                               ifelse(geom_better, "Geometric", "Exponential")),
               hjust = 0, size = 3.5,
               color = ifelse(geom_better, "blue", "red")) +
      labs(
        title = "Survival Decay: Geometric vs Exponential",
        subtitle = sprintf("Best fit: %s model (higher KS p-value = better fit)",
                           ifelse(geom_better, "Geometric (H0)", "Exponential (H1)")),
        x = "Walker Termination Order",
        y = "Steps Survived",
        caption = "Blue dashed: Geometric (H0) | Red solid: Exponential (H1)"
      ) +
      theme_minimal(base_size = 12) +
      theme(
        panel.background = element_rect(fill = "gray70"),
        plot.background = element_rect(fill = "gray70"),
        plot.title = element_text(hjust = 0.5, face = "bold"),
        plot.subtitle = element_text(hjust = 0.5,
                                     color = ifelse(geom_better, "blue", "red"))
      )
  })

  # Hypothesis test summary text
  output$hypothesis_stats <- renderPrint({
    req(sim_result())
    result <- sim_result()

    # Build survival data with termination order
    survdat <- data.frame(
      order = sapply(result$walkers, function(w) {
        if (!is.null(w$termination_order)) w$termination_order else NA
      }),
      steps = sapply(result$walkers, function(w) w$steps)
    )

    survdat <- survdat[!is.na(survdat$order), ]

    if (nrow(survdat) < 10) {
      cat("Insufficient data for hypothesis testing.\n")
      cat("Need at least 10 walkers with termination_order field.\n")
      return()
    }

    cat("=== HYPOTHESIS TEST: GEOMETRIC vs EXPONENTIAL DECAY ===\n\n")
    cat("H0 (Geometric): Step counts follow a geometric distribution\n")
    cat("H1 (Exponential): Step counts follow an exponential distribution\n\n")

    # Geometric model
    geom_p <- 1 / (1 + mean(survdat$steps))
    cat("--- Geometric Model (H0) ---\n")
    cat(sprintf("Estimated p: %.4f\n", geom_p))
    cat(sprintf("Expected mean: %.1f steps\n", (1 - geom_p) / geom_p))

    # Exponential model
    exp_rate <- 1 / mean(survdat$steps)
    survdat$log_steps <- log(survdat$steps + 1)
    exp_model <- lm(log_steps ~ order, data = survdat)

    cat("\n--- Exponential Model (H1) ---\n")
    cat(sprintf("Rate parameter (lambda): %.6f\n", exp_rate))
    cat(sprintf("Expected mean: %.1f steps\n", 1 / exp_rate))
    cat(sprintf("Decay coefficient: %.6f\n", coef(exp_model)[2]))

    # KS tests
    geom_ks <- tryCatch({
      ks.test(survdat$steps, function(x) pgeom(x, prob = geom_p))
    }, error = function(e) list(p.value = 0, statistic = 1))

    exp_ks <- tryCatch({
      ks.test(survdat$steps, function(x) pexp(x, rate = exp_rate))
    }, error = function(e) list(p.value = 0, statistic = 1))

    cat("\n--- Kolmogorov-Smirnov Tests ---\n")
    cat(sprintf("Geometric KS statistic: %.4f, p-value: %.4f\n",
                geom_ks$statistic, geom_ks$p.value))
    cat(sprintf("Exponential KS statistic: %.4f, p-value: %.4f\n",
                exp_ks$statistic, exp_ks$p.value))

    # Significance threshold
    alpha <- 0.05

    cat("\n=== CONCLUSION ===\n")

    # Check if BOTH models are rejected (p < alpha)
    geom_rejected <- geom_ks$p.value < alpha
    exp_rejected <- exp_ks$p.value < alpha

    if (geom_rejected && exp_rejected) {
      cat("NEITHER model fits well (both p-values < 0.05)\n")
      cat("The step distribution may be more complex than geometric or exponential.\n")
      cat("Consider: mixture models, heavy-tailed distributions, or spatial effects.\n")
    } else if (!geom_rejected && !exp_rejected) {
      # Both acceptable - compare which is better
      if (geom_ks$p.value > exp_ks$p.value) {
        cat("GEOMETRIC model fits better (p=", round(geom_ks$p.value, 4), ")\n")
        cat("Step counts suggest discrete process with constant termination probability.\n")
      } else {
        cat("EXPONENTIAL model fits better (p=", round(exp_ks$p.value, 4), ")\n")
        cat("Step counts suggest continuous decay process.\n")
      }
    } else if (!geom_rejected) {
      cat("GEOMETRIC model fits (p=", round(geom_ks$p.value, 4), ")\n")
      cat("Exponential model rejected (p=", round(exp_ks$p.value, 4), ")\n")
    } else {
      cat("EXPONENTIAL model fits (p=", round(exp_ks$p.value, 4), ")\n")
      cat("Geometric model rejected (p=", round(geom_ks$p.value, 4), ")\n")
    }
  })

  # PAGE 6: Debug - Versions
  output$debug_versions <- renderText({
    paste0(
      "=== PACKAGE VERSIONS ===\n",
      "randomwalk: ", packageVersion("randomwalk"), "\n",
      "shiny: ", packageVersion("shiny"), "\n",
      "ggplot2: ", packageVersion("ggplot2"), "\n",
      if (requireNamespace("mirai", quietly = TRUE)) {
        paste0("mirai: ", packageVersion("mirai"), "\n")
      } else "",
      if (requireNamespace("nanonext", quietly = TRUE)) {
        paste0("nanonext: ", packageVersion("nanonext"), "\n")
      } else "",
      if (requireNamespace("crew", quietly = TRUE)) {
        paste0("crew: ", packageVersion("crew"), "\n")
      } else ""
    )
  })

  # PAGE 5: Debug - Inputs
  output$debug_inputs <- renderText({
    paste0(
      "=== CURRENT INPUTS ===\n",
      "Grid Size: ", input$grid_size, "\n",
      "Number of Walkers: ", input$n_walkers, "\n",
      "Mode: sync sequential (workers=0)\n",
      "Neighborhood: ", input$neighborhood, "\n",
      "Boundary: ", input$boundary, "\n",
      "Max Steps: ", input$max_steps
    )
  })

  # PAGE 5: Debug - Backend
  output$debug_backend <- renderText({
    env_is_webr <- is_webr()

    paste0(
      "=== BACKEND INFORMATION ===\n",
      "Environment: WebR/Browser\n",
      "R Version: ", R.version.string, "\n",
      "Backend: synchronous (no parallelization)\n",
      "Sync Mode: static (grid snapshots)\n",
      "\n=== ENVIRONMENT DETECTION ===\n",
      ".webr_env exists: ", exists(".webr_env", envir = .GlobalEnv), "\n",
      "WEBR env var: ", Sys.getenv("WEBR"), "\n",
      "is_webr() result: ", env_is_webr
    )
  })

  # PAGE 5: Debug - Periodic Updates
  output$debug_periodic <- renderText({
    # Depend on timer to update every 100ms
    autoInvalidate()

    # Enhanced debug info during execution
    state_info <- switch(sim_state(),
      "idle" = "💤 Idle - waiting for user to click 'Run Simulation'",
      "running" = {
        elapsed <- if (!is.null(sim_start_time())) {
          as.numeric(difftime(Sys.time(), sim_start_time(), units = "secs"))
        } else {
          0
        }
        paste0("Running - ", format_duration(elapsed), " elapsed")
      },
      "complete" = {
        elapsed <- if (!is.null(sim_end_time()) && !is.null(sim_start_time())) {
          as.numeric(difftime(sim_end_time(), sim_start_time(), units = "secs"))
        } else {
          0
        }
        paste0("Complete - took ", format_duration(elapsed))
      },
      "error" = "❌ Error - see status tab for details",
      "Unknown state"
    )

    paste0(
      "=== PERIODIC UPDATES ===\n",
      "Current Time: ", format(Sys.time(), "%Y-%m-%d %H:%M:%S"), "\n",
      "Simulation State: ", state_info, "\n",
      "Simulation Count: ", sim_count(), "\n",
      "Result Available: ", if (is.null(sim_result())) "No" else "Yes", "\n",
      "Active Tab: ", input$tabs, "\n",
      if (sim_state() == "running" && !is.null(sim_start_time())) {
        paste0(
          "\n=== EXECUTION INFO ===\n",
          "Grid Size: ", input$grid_size, "×", input$grid_size, "\n",
          "Walkers: ", input$n_walkers, "\n",
          "Workers: 0 (sync sequential)\n",
          "Backend: synchronous"
        )
      } else {
        ""
      }
    )
  })
}

# ============================================================================
# Run Shiny App
# ============================================================================

shinyApp(ui = ui, server = server)

Tips

  • Larger grid (200-400): More exploration space
  • Fewer walkers (100-500): Faster simulation
  • 8-hood: Diagonal movement for different patterns
  • Wrap boundary: Toroidal grid (no edge termination)

Build Info

randomwalk 2.1.1 | Git a38b203 | R 4.5.2 | Built 2026-02-18 17:34:52