R/ShinySimulatorTabs.R

Defines functions sim_compare_tab_server sim_compare_tab_ui sim_report_tab_server sim_report_tab_ui sim_tab_report_frame_html sim_compare_theme_js sim_tab_return_period_plot sim_tab_metrics_table sim_tab_compare_entry sim_tab_return_period_curve sim_tab_describe_settings sim_tab_fmt_pct sim_tab_fmt_int sim_tab_fmt_amount sim_tab_open_simulator_button sim_tab_empty_state

#shiny modules for the Report and Compare tabs of the claims simulator
#
#both modules read the latest run from a reactive (last_run) that holds NULL until the
#first successful run and then list(id, settings, data, finished)

# ---------------------------------------------------------------- helpers

#' Empty-state card used by the Report and Compare tabs
#'
#' @param icon_name Font Awesome icon name.
#' @param title Short title.
#' @param text Explanation shown under the title.
#' @param ... Optional buttons shown under the text.
#' @noRd
sim_tab_empty_state <- function(icon_name, title, text, ...) {
  bslib::card(
    class = "sim-empty-card",
    bslib::card_body(
      div(class = "sim-empty-icon", icon(icon_name)),
      div(class = "sim-empty-title", title),
      p(class = "sim-empty-text", text),
      ...
    )
  )
}

#' Button that opens the Claims Simulator tab
#'
#' Clicks the navbar link in the browser, so it works from any module.
#' @noRd
sim_tab_open_simulator_button <- function() {
  tags$button(
    type = "button",
    class = "btn btn-primary",
    onclick = "var l = document.querySelector('.navbar a[data-value=\"simulator\"]'); if (l) l.click();",
    icon("play"), " Open the simulator"
  )
}

#' Format an amount with thousands separators
#'
#' @param x A number.
#' @param digits Decimal places; chosen from the size of \code{x} when NULL.
#' @return A string, or a dash when \code{x} is missing.
#' @noRd
sim_tab_fmt_amount <- function(x, digits = NULL) {
  if (is.null(x) || length(x) == 0 || is.na(x[1]) || !is.numeric(x)) return(intToUtf8(8212))
  x <- as.numeric(x[1])
  if (is.null(digits)) digits <- if (abs(x) >= 1000) 0 else 2
  formatC(x, format = "f", digits = digits, big.mark = ",")
}

#' Format a whole number with thousands separators
#' @noRd
sim_tab_fmt_int <- function(x) {
  if (is.null(x) || length(x) == 0 || is.na(x[1])) return(intToUtf8(8212))
  formatC(round(as.numeric(x[1])), format = "d", big.mark = ",")
}

#' Format a probability as a percentage
#' @noRd
sim_tab_fmt_pct <- function(p, digits = 1) {
  if (is.null(p) || length(p) == 0 || is.na(p[1])) return(intToUtf8(8212))
  p <- as.numeric(p[1])
  #small probabilities get an extra decimal so they do not round to zero
  if (p > 0 && p < 0.01 && digits < 2) digits <- 2
  paste0(formatC(100 * p, format = "f", digits = digits), "%")
}

#' Short description of a run's settings, e.g. for the compare tab
#'
#' @param s The list of \code{simulate_function} arguments.
#' @return A single string such as
#'   "Poisson (lamda = 5) / Log-Normal (mu = 8, sigma = 1.5), EEL Limited Layer 200,000 xs 50,000, AL none".
#' @noRd
sim_tab_describe_settings <- function(s) {
  is_number <- function(x) is.numeric(x) && length(x) == 1 && !is.na(x)
  fmt <- function(x) {
    if (!is_number(x)) return("?")
    formatC(x, format = "f", digits = if (x == round(x)) 0 else 2, big.mark = ",")
  }
  fmt_param <- function(x) sub("\\.?0+$", "", formatC(x, format = "f", digits = 4))

  distr_text <- function(options, id, values) {
    known <- is.character(id) && length(id) == 1 && id %in% names(options)
    label <- if (known) options[[id]]@distr_label else if (is.character(id) && length(id) == 1) gsub("_", " ", id) else "?"
    values <- suppressWarnings(as.numeric(unlist(values)))
    if (length(values) == 0 || anyNA(values)) return(label)
    #labels such as "lambda (mean claims)" are shortened to their first word
    labels <- if (known) sub("\\s*\\(.*$", "", options[[id]]@param_labels) else NULL
    shown <- if (length(labels) == length(values)) paste(labels, "=", fmt_param(values)) else fmt_param(values)
    paste0(label, " (", paste(shown, collapse = ", "), ")")
  }

  structure_text <- function(kind, deductible, limit) {
    if (!is.character(kind) || length(kind) != 1 || kind == "No Reinsurance Structure") return("none")
    switch(
      kind,
      "Unlimited Layer" = paste("Unlimited Layer xs", fmt(deductible)),
      "Limited Layer" = paste("Limited Layer", fmt(limit), "xs", fmt(deductible)),
      "Exclude Layer" = paste("Exclude Layer", fmt(limit), "xs", fmt(deductible)),
      kind
    )
  }

  eel <- structure_text(s$reinsuranceStructureEEL, s$reinsurance_structure_eel_dedctible_amount,
                        s$reinsurance_structure_eel_limit_amount)
  if (identical(s$reinsuranceStructureEEL, "Limited Layer") &&
      isTRUE(s$reinsuranceStructureLimitedReinstatements) &&
      is_number(s$reinsuranceStructureReinstatementLimit)) {
    eel <- paste0(eel, " (", fmt(s$reinsuranceStructureReinstatementLimit), " reinst.)")
  }
  al <- structure_text(s$reinsuranceStructureAL, s$reinsurance_structure_al_dedctible_amount,
                       s$reinsurance_structure_al_limit_amount)

  slices <- if (isTRUE(s$paretoSlice) && is_number(s$pareto_slice_times)) {
    paste(fmt(s$pareto_slice_times), if (s$pareto_slice_times == 1) "Pareto slice" else "Pareto slices")
  }
  cap <- if (isTRUE(s$sevCapBinary)) paste("cap", fmt(s$sev_cap_amount))
  seed <- if (isTRUE(s$seedSetBinary) && is_number(s$seedValue)) paste("seed", fmt(s$seedValue))

  parts <- c(
    paste(distr_text(freq_dist_options, s$freqDistr, s$freq_params), "/",
          distr_text(sev_dist_options, s$sevDistr, s$sev_params)),
    slices, cap, paste("EEL", eel), paste("AL", al), seed
  )
  paste(parts, collapse = ", ")
}

#' Return periods at which the compare chart evaluates every run
#'
#' A fixed log-spaced grid, so curves of different runs line up point for point.
#' @noRd
sim_tab_return_period_grid <- exp(seq(log(2), log(1e6), length.out = 700))

#' Losses by return period for one run
#'
#' Uses the same quantile approach as the report: the loss at return period r is the
#' quantile of the totals at probability 1 - 1/r. Return periods stop at n/10, so at
#' least 10 simulations lie beyond each point.
#' @param totals Numeric vector of simulated totals.
#' @return A data frame with columns rp and value (possibly empty).
#' @noRd
sim_tab_return_period_curve <- function(totals) {
  totals <- totals[!is.na(totals)]
  max_rp <- length(totals) / 10
  if (max_rp < 2) return(data.frame(rp = numeric(0), value = numeric(0)))
  rps <- c(sim_tab_return_period_grid[sim_tab_return_period_grid < max_rp], max_rp)
  data.frame(rp = rps, value = stats::quantile(totals, 1 - 1 / rps, names = FALSE))
}

#' Everything the compare tab keeps for one run
#'
#' Metrics come from summarise_simulation; only the return-period curve is kept for the
#' chart, not the simulated totals, so six stored runs stay small.
#' @param run A last_run value: list(id, settings, data, finished).
#' @return A list with id, name, description, modelled_label, digits, metrics and curve.
#' @noRd
sim_tab_compare_entry <- function(run) {
  summary <- summarise_simulation(run$settings, run$data)
  totals <- summary$totals
  if (is.null(totals)) totals <- run$data$total_claims
  totals <- as.numeric(totals)
  totals <- totals[!is.na(totals)]

  pick <- function(x, name) {
    value <- if (is.list(x)) x[[name]] else NULL
    if (is.null(value) || length(value) == 0) NA_real_ else as.numeric(value[1])
  }
  stats <- summary$stats
  layer <- summary$layer
  n <- if (!is.null(summary$n)) as.integer(summary$n[1]) else length(totals)

  list(
    id = run$id,
    name = paste("Run", run$id),
    description = sim_tab_describe_settings(run$settings),
    modelled_label = if (is.character(summary$modelled_label)) summary$modelled_label[1] else "Total claims",
    #headline amounts of a run share one decimal style, set by the scale of its results
    digits = if (length(totals) > 0 && max(abs(totals)) >= 1000) 0 else 2,
    metrics = list(
      n = n,
      mean = pick(stats, "mean"),
      sd = pick(stats, "sd"),
      median = pick(stats, "median"),
      var99 = pick(stats, "var99"),
      var995 = pick(stats, "var995"),
      tvar995 = pick(stats, "tvar995"),
      hit_prob = pick(layer, "hit_prob"),
      loss_on_line = pick(layer, "loss_on_line")
    ),
    curve = sim_tab_return_period_curve(totals),
    finished = run$finished
  )
}

#' Side-by-side metrics table for the compare tab
#'
#' @param entries List of compare entries (see sim_tab_compare_entry) to show as columns.
#' @param names Display names, one per entry.
#' @return An htmltools table.
#' @noRd
sim_tab_metrics_table <- function(entries, names) {
  amount_row <- function(label, key) {
    tags$tr(
      tags$td(label),
      lapply(entries, function(e) tags$td(sim_tab_fmt_amount(e$metrics[[key]], e$digits)))
    )
  }
  has_value <- function(key) any(vapply(entries, function(e) !is.na(e$metrics[[key]]), logical(1)))

  tags$table(
    class = "sim-compare-table",
    tags$thead(tags$tr(
      tags$th("Metric"),
      lapply(seq_along(entries), function(i) tags$th(
        div(class = "sim-run-name", names[i]),
        div(class = "sim-run-desc", entries[[i]]$description)
      ))
    )),
    tags$tbody(
      tags$tr(tags$td("Modelled result"), lapply(entries, function(e) tags$td(e$modelled_label))),
      tags$tr(tags$td("Simulations"), lapply(entries, function(e) tags$td(sim_tab_fmt_int(e$metrics$n)))),
      amount_row("Mean", "mean"),
      amount_row("Standard deviation", "sd"),
      amount_row("Median", "median"),
      amount_row("VaR 99%", "var99"),
      amount_row("VaR 99.5%", "var995"),
      amount_row("TVaR 99.5%", "tvar995"),
      if (has_value("hit_prob")) tags$tr(
        tags$td("Chance the layers are hit"),
        lapply(entries, function(e) tags$td(sim_tab_fmt_pct(e$metrics$hit_prob)))
      ),
      if (has_value("loss_on_line")) tags$tr(
        tags$td("Loss on line"),
        lapply(entries, function(e) tags$td(sim_tab_fmt_pct(e$metrics$loss_on_line, 2)))
      )
    )
  )
}

#' Overlaid return-period chart for the compare tab
#'
#' @param entries List of compare entries to draw.
#' @param names Display names, one per entry.
#' @param dark TRUE to use the dark theme colours.
#' @return A plotly object.
#' @noRd
sim_tab_return_period_plot <- function(entries, names, dark = FALSE) {
  #the chart stops where the smallest run stops
  max_rp <- min(vapply(entries, function(e) e$metrics$n, numeric(1))) / 10
  colours <- if (dark) {
    list(font = "#cbd5e1", muted = "#94a3b8", grid = "rgba(148, 163, 184, 0.18)",
         line = "rgba(148, 163, 184, 0.35)", marker = "#f87171")
  } else {
    list(font = "#334155", muted = "#64748b", grid = "rgba(148, 163, 184, 0.30)",
         line = "rgba(100, 116, 139, 0.45)", marker = "#dc2626")
  }
  #series colours that read well on both backgrounds
  palette <- c("#3b82f6", "#f97316", "#10b981", "#a855f7", "#ef4444", "#eab308")

  p <- plotly::plot_ly()
  for (i in seq_along(entries)) {
    curve <- entries[[i]]$curve
    curve <- curve[curve$rp <= max_rp * (1 + 1e-9), , drop = FALSE]
    if (nrow(curve) == 0) next
    p <- plotly::add_lines(
      p, x = curve$rp, y = curve$value, name = names[i],
      line = list(color = palette[(i - 1) %% length(palette) + 1], width = 2.2),
      text = paste0(names[i], "<br>1 in ", formatC(curve$rp, format = "f", digits = 1, big.mark = ","),
                    ": ", vapply(curve$value, sim_tab_fmt_amount, character(1), digits = entries[[i]]$digits)),
      hoverinfo = "text"
    )
  }

  ticks <- c(2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000, 100000)
  ticks <- ticks[ticks <= max_rp]
  shapes <- NULL
  annotations <- NULL
  if (200 <= max_rp) {
    #shapes on a log axis take log10 coordinates
    shapes <- list(list(type = "line", xref = "x", yref = "paper", x0 = log10(200), x1 = log10(200),
                        y0 = 0, y1 = 1, line = list(color = colours$marker, width = 1.5, dash = "dash")))
    annotations <- list(list(x = log10(200), y = 1, xref = "x", yref = "paper", text = "1 in 200",
                             showarrow = FALSE, xanchor = "left", yanchor = "top", xshift = 4,
                             font = list(color = colours$marker, size = 11)))
  }

  p <- plotly::layout(
    p,
    paper_bgcolor = "rgba(0,0,0,0)",
    plot_bgcolor = "rgba(0,0,0,0)",
    font = list(color = colours$font, family = "Inter, system-ui, sans-serif"),
    margin = list(l = 70, r = 20, t = 20, b = 60),
    hovermode = "closest",
    xaxis = list(
      type = "log", title = list(text = "Return period", font = list(color = colours$muted)),
      tickvals = ticks, ticktext = paste("1 in", formatC(ticks, format = "d", big.mark = ",")),
      gridcolor = colours$grid, linecolor = colours$line, tickcolor = colours$line,
      zeroline = FALSE, showline = TRUE, range = log10(c(2, max_rp * 1.02))
    ),
    yaxis = list(
      title = list(text = "Total claims", font = list(color = colours$muted)),
      tickformat = ",", gridcolor = colours$grid, linecolor = colours$line, tickcolor = colours$line,
      zerolinecolor = colours$grid, rangemode = "tozero"
    ),
    legend = list(orientation = "h", x = 0, y = 1.08, xanchor = "left", font = list(color = colours$font)),
    shapes = shapes,
    annotations = annotations
  )
  plotly::config(p, displaylogo = FALSE, modeBarButtonsToRemove = list("select2d", "lasso2d", "autoScale2d"))
}

# ---------------------------------------------------------------- styles and scripts

#' Styles shared by the Report and Compare tabs
#'
#' The colours come from the --sim-* variables of the app, so both themes match.
#' @noRd
sim_tab_css <- "
.sim-tab-header {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: space-between;
  gap: 0.5rem 1rem;
  margin: 0.25rem 0 1rem 0;
}

.sim-tab-header .shiny-html-output:empty {
  display: none;
}

/* ---------- empty states ---------- */
.sim-empty-card .card-body {
  text-align: center;
  padding: 3rem 1.5rem;
}

.sim-empty-icon {
  width: 56px;
  height: 56px;
  border-radius: 16px;
  margin: 0 auto 1rem auto;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--sim-accent-soft);
  color: var(--sim-accent-text);
  font-size: 22px;
}

.sim-empty-title {
  font-weight: 700;
  font-size: 1.1rem;
  color: var(--sim-heading);
}

.sim-empty-text {
  color: var(--sim-muted);
  max-width: 520px;
  margin: 0.35rem auto 1rem auto;
}

.sim-empty-card .sim-run-desc {
  margin: 0 auto 1rem auto;
  max-width: 520px;
}

/* ---------- report frame ---------- */
.sim-report-frame-wrap {
  border: 1px solid var(--sim-border);
  border-radius: var(--sim-radius);
  box-shadow: var(--sim-shadow);
  background: var(--sim-card-bg);
  overflow: hidden;
}

.sim-report-frame {
  display: block;
  width: 100%;
  min-height: 480px;
  border: 0;
  background: transparent;
}

.sim-report-building {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  min-height: 200px;
  color: var(--sim-muted);
  border: 1px dashed var(--sim-border-strong);
  border-radius: var(--sim-radius);
}

/* ---------- compare: stored runs ---------- */
.sim-run-list {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.sim-run-item {
  border: 1px solid var(--sim-border);
  border-radius: 12px;
  padding: 0.7rem 0.8rem;
  background: var(--sim-card-bg);
}

.sim-run-item-top {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

.sim-run-item .shiny-input-container,
.sim-run-item .bslib-input-switch {
  margin-bottom: 0;
  width: auto;
}

.sim-run-item .sim-run-name-input {
  flex: 1 1 120px;
  min-width: 0;
}

.sim-run-item .sim-run-name-input .form-control {
  padding: 0.3rem 0.6rem;
  font-weight: 600;
}

.sim-run-item .form-switch .form-check-label {
  font-size: 0.8rem;
}

.sim-run-id {
  flex: 0 0 auto;
  font-size: 0.7rem;
  font-weight: 800;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--sim-accent-text);
  background: var(--sim-accent-soft);
  padding: 0.15rem 0.5rem;
  border-radius: 999px;
}

.sim-run-remove {
  flex: 0 0 auto;
  margin-left: auto;
  color: var(--sim-muted);
  border-color: var(--sim-border-strong);
  padding: 0.2rem 0.5rem;
}

.sim-run-remove:hover {
  color: #ffffff;
  background: #dc2626;
  border-color: #dc2626;
}

.sim-run-desc {
  font-size: 0.8rem;
  font-weight: 400;
  color: var(--sim-muted);
  margin-top: 0.35rem;
  line-height: 1.35;
}

.sim-run-list-actions {
  display: flex;
  justify-content: flex-end;
  margin-top: 0.75rem;
}

/* ---------- compare: table and chart ---------- */
.sim-compare-main {
  display: grid;
  gap: 1rem;
}

.sim-compare-table-wrap {
  overflow-x: auto;
}

.sim-compare-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.9rem;
}

.sim-compare-table th,
.sim-compare-table td {
  padding: 0.55rem 0.75rem;
  border-bottom: 1px solid var(--sim-border);
  text-align: right;
  vertical-align: bottom;
}

.sim-compare-table th {
  color: var(--sim-heading);
  font-weight: 700;
  min-width: 140px;
}

.sim-compare-table th:first-child,
.sim-compare-table td:first-child {
  text-align: left;
  min-width: 0;
}

.sim-compare-table td {
  font-variant-numeric: tabular-nums;
}

.sim-compare-table td:first-child {
  color: var(--sim-label);
  font-weight: 600;
}

.sim-compare-table tbody tr:last-child td {
  border-bottom: 0;
}

.sim-compare-table .sim-run-desc {
  font-size: 0.75rem;
}

.sim-compare-chart .js-plotly-plot .plotly .modebar-btn path {
  fill: var(--sim-muted);
}

.sim-compare-chart .shiny-output-error-validation {
  color: var(--sim-muted);
  padding: 2rem 1rem;
  text-align: center;
}

.sim-compare-note {
  display: flex;
  gap: 0.5rem;
  align-items: flex-start;
  font-size: 0.85rem;
  color: var(--sim-muted);
  padding: 0.65rem 0.8rem;
  border: 1px dashed var(--sim-border-strong);
  border-radius: 10px;
}
"

#' Script that sizes the in-app report frame and keeps its theme in step with the app
#'
#' The report is shown in a srcdoc iframe, which is same-origin, so the frame document
#' can be reached from the page. The load event of frames does not bubble but can be
#' captured at the document level, which also covers frames rendered later by shiny.
#' @noRd
sim_report_frame_js <- "
(function () {
  function appTheme() {
    return document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light';
  }

  function frameDocument(frame) {
    try {
      return frame.contentDocument || (frame.contentWindow && frame.contentWindow.document) || null;
    } catch (e) {
      return null;
    }
  }

  /* the report has its own switch; inside the app it follows the app theme instead */
  function syncTheme(frame) {
    var doc = frameDocument(frame);
    if (!doc || !doc.documentElement) return;
    var theme = appTheme();
    if (doc.documentElement.getAttribute('data-theme') !== theme) {
      doc.documentElement.setAttribute('data-theme', theme);
    }
  }

  /* the frame takes the height of its content, so the page has a single scrollbar */
  function syncHeight(frame) {
    var doc = frameDocument(frame);
    if (!doc || !doc.documentElement) return;
    var height = Math.max(
      doc.documentElement.scrollHeight || 0,
      doc.body ? doc.body.scrollHeight || 0 : 0
    );
    if (height > 0) frame.style.height = (height + 2) + 'px';
  }

  function setupFrame(frame) {
    var doc = frameDocument(frame);
    if (!doc || !doc.documentElement) return;

    if (!doc.getElementById('sim-report-frame-style')) {
      var style = doc.createElement('style');
      style.id = 'sim-report-frame-style';
      style.textContent = '.theme-switch{display:none!important}';
      (doc.head || doc.documentElement).appendChild(style);
    }

    syncTheme(frame);
    syncHeight(frame);

    if (window.ResizeObserver && !frame.simResizeObserver) {
      var observer = new ResizeObserver(function () { syncHeight(frame); });
      observer.observe(doc.documentElement);
      if (doc.body) observer.observe(doc.body);
      frame.simResizeObserver = observer;
    }

    /* the frame never scrolls, so links to report sections scroll the app page instead */
    if (!frame.simLinksBound) {
      frame.simLinksBound = true;
      doc.addEventListener('click', function (event) {
        var link = event.target && event.target.closest ? event.target.closest('a[href^=\"#\"]') : null;
        if (!link) return;
        var id = link.getAttribute('href').slice(1);
        var target = id ? doc.getElementById(id) : null;
        if (!target || !target.scrollIntoView) return;
        event.preventDefault();
        target.scrollIntoView({ behavior: 'smooth', block: 'start' });
      });
    }
  }

  document.addEventListener('load', function (event) {
    var el = event.target;
    if (el && el.classList && el.classList.contains('sim-report-frame')) setupFrame(el);
  }, true);

  function syncAll() {
    var frames = document.querySelectorAll('iframe.sim-report-frame');
    for (var i = 0; i < frames.length; i++) {
      syncTheme(frames[i]);
      syncHeight(frames[i]);
    }
  }

  if (window.MutationObserver) {
    new MutationObserver(syncAll).observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['data-bs-theme']
    });
  }
  window.addEventListener('resize', syncAll);
})();
"

#' Script that tells the compare module which theme the app is showing
#'
#' The chart is drawn on the server with colours for that theme, on a transparent
#' background, and redrawn when the theme changes.
#' @param input_id The namespaced id of the theme input.
#' @noRd
sim_compare_theme_js <- function(input_id) {
  paste0("
(function () {
  var inputId = '", input_id, "';
  function send() {
    if (!window.Shiny || !window.Shiny.setInputValue) return;
    var theme = document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light';
    window.Shiny.setInputValue(inputId, theme);
  }
  if (window.jQuery) {
    window.jQuery(document).on('shiny:connected shiny:sessioninitialized', send);
  }
  if (window.MutationObserver) {
    new MutationObserver(send).observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['data-bs-theme']
    });
  }
  send();
})();
")
}

#' Prepare a report file for display inside the app
#'
#' Reads the HTML written by write_simulation_report and adds, at the end of its head,
#' a style that hides the report's own theme switch and a script that keeps the
#' report's data-theme equal to the app's data-bs-theme from the first paint.
#' @param file Path of the report file.
#' @return The HTML as a single string.
#' @noRd
sim_tab_report_frame_html <- function(file) {
  html <- paste(readLines(file, encoding = "UTF-8", warn = FALSE), collapse = "\n")
  inject <- paste0(
    "<style>.theme-switch{display:none!important}</style>\n",
    "<script>(function(){",
    "function parentTheme(){try{var p=window.parent&&window.parent!==window?window.parent.document:null;",
    "return p?p.documentElement.getAttribute('data-bs-theme'):null;}catch(e){return null;}}",
    "function sync(){var t=parentTheme();if(!t)return;",
    "if(document.documentElement.getAttribute('data-theme')!==t)document.documentElement.setAttribute('data-theme',t);}",
    "sync();document.addEventListener('DOMContentLoaded',sync);",
    "if(window.MutationObserver){new MutationObserver(sync).observe(document.documentElement,{attributes:true,attributeFilter:['data-theme']});}",
    "})();</script>\n"
  )
  if (grepl("</head>", html, fixed = TRUE)) {
    sub("</head>", paste0(inject, "</head>"), html, fixed = TRUE)
  } else {
    paste0(inject, html)
  }
}

# ---------------------------------------------------------------- report tab

#' UI of the Report tab
#'
#' @param id Module id.
#' @return A \code{bslib::nav_panel} for the simulator's navbar.
#' @noRd
sim_report_tab_ui <- function(id) {
  ns <- NS(id)
  bslib::nav_panel(
    title = "Report",
    value = "report",
    icon = icon("file-lines"),
    htmltools::singleton(tags$head(tags$style(HTML(sim_tab_css)))),
    tags$script(HTML(sim_report_frame_js)),
    div(
      class = "sim-tab-header",
      div(
        h2(class = "sim-page-title", "Report"),
        p(class = "sim-page-subtitle", "The full report of the latest run, ready to read here or to download.")
      ),
      uiOutput(ns("download_ui"))
    ),
    uiOutput(ns("report_view"))
  )
}

#' Server of the Report tab
#'
#' Builds the report of the latest run with write_simulation_report, but only while the
#' tab is showing and only once per run, and shows it in a frame sized to its content.
#'
#' @param id Module id.
#' @param last_run Reactive holding NULL, or list(id, settings, data, finished) of the latest run.
#' @param navbar_input Reactive returning the selected navbar tab value.
#' @noRd
sim_report_tab_server <- function(id, last_run, navbar_input) {
  moduleServer(id, function(input, output, session) {
    ns <- session$ns

    #the report of one run: list(id, file, html, error)
    built <- reactiveVal(NULL)

    #remove the temporary report file when the session ends
    session$onSessionEnded(function() {
      current <- isolate(built())
      if (!is.null(current$file)) unlink(current$file)
    })

    build_report <- function(run) {
      file <- tempfile("simulation_report_", fileext = ".html")
      shiny::withProgress(message = "Building report", value = 0.3, detail = "Preparing charts and tables", {
        #the report is assembled in R (see write_simulation_report), so no pandoc is needed
        write_simulation_report(file = file, settings = run$settings, results = run$data)
        incProgress(0.7, detail = "Done")
      })
      list(id = run$id, file = file, html = sim_tab_report_frame_html(file), error = NULL)
    }

    #build lazily: only while the tab is showing and only once per run
    observe({
      run <- last_run()
      if (is.null(run) || is.null(run$id)) return(NULL)
      if (!identical(navbar_input(), "report")) return(NULL)
      current <- isolate(built())
      if (!is.null(current) && identical(current$id, run$id)) return(NULL)

      result <- tryCatch(
        build_report(run),
        error = function(cond) {
          showNotification(
            paste("The report could not be created:", conditionMessage(cond)),
            type = "error",
            duration = NULL
          )
          list(id = run$id, file = NULL, html = NULL, error = conditionMessage(cond))
        }
      )
      if (!is.null(current$file)) unlink(current$file)
      built(result)
    })

    output$report_view <- renderUI({
      run <- last_run()
      if (is.null(run)) {
        return(sim_tab_empty_state(
          "file-lines", "No report yet",
          "Run a simulation to see its report here.",
          sim_tab_open_simulator_button()
        ))
      }
      current <- built()
      if (is.null(current) || !identical(current$id, run$id)) {
        return(div(
          class = "sim-report-building",
          tags$span(class = "spinner-border spinner-border-sm", role = "status", `aria-hidden` = "true"),
          "Building the report..."
        ))
      }
      if (!is.null(current$error)) {
        return(sim_tab_empty_state(
          "triangle-exclamation", "The report could not be created", current$error
        ))
      }
      div(
        class = "sim-report-frame-wrap",
        tags$iframe(
          class = "sim-report-frame",
          title = "Simulation report",
          srcdoc = current$html
        )
      )
    })

    output$download_ui <- renderUI({
      req(last_run())
      downloadButton(ns("download_report"), "Download report", icon = icon("download"), class = "btn-outline-primary")
    })
    #the empty container has no size, so Shiny would treat it as hidden and never render the button
    outputOptions(output, "download_ui", suspendWhenHidden = FALSE)

    output$download_report <- downloadHandler(
      filename = "simulation_report.html",
      content = function(file) {
        run <- isolate(last_run())
        req(run)
        showNotification(
          "Preparing report, save dialog will appear shortly...",
          type = "message",
          duration = 3
        )
        #the report shown in the tab is the same file, so reuse it when it is current
        current <- isolate(built())
        if (!is.null(current) && identical(current$id, run$id) &&
            !is.null(current$file) && file.exists(current$file)) {
          file.copy(current$file, file, overwrite = TRUE)
          return(invisible(file))
        }
        tryCatch(
          write_simulation_report(file = file, settings = run$settings, results = run$data),
          error = function(cond) {
            #without this the download just fails, with the reason only in the R console
            showNotification(
              paste("The report could not be created:", conditionMessage(cond)),
              type = "error",
              duration = NULL
            )
            stop(cond)
          }
        )
      }
    )
  })
}

# ---------------------------------------------------------------- compare tab

#' UI of the Compare tab
#'
#' @param id Module id.
#' @return A \code{bslib::nav_panel} for the simulator's navbar.
#' @noRd
sim_compare_tab_ui <- function(id) {
  ns <- NS(id)
  bslib::nav_panel(
    title = "Compare",
    value = "compare",
    icon = icon("scale-balanced"),
    htmltools::singleton(tags$head(tags$style(HTML(sim_tab_css)))),
    tags$script(HTML(sim_compare_theme_js(ns("app_theme")))),
    div(
      class = "sim-tab-header",
      div(
        h2(class = "sim-page-title", "Compare runs"),
        p(class = "sim-page-subtitle", textOutput(ns("stored_note"), inline = TRUE))
      )
    ),
    uiOutput(ns("empty_state")),
    conditionalPanel(
      condition = "output.has_comparison",
      ns = ns,
      bslib::layout_columns(
        col_widths = bslib::breakpoints(sm = 12, lg = c(4, 8), xxl = c(3, 9)),

        bslib::card(
          sim_card_header("list-check", "Stored runs", "Tick the runs to compare, rename or remove them."),
          bslib::card_body(
            uiOutput(ns("run_list")),
            div(
              class = "sim-run-list-actions",
              actionButton(ns("clear_all"), "Clear all", icon = icon("trash-can"), class = "btn-outline-secondary btn-sm")
            )
          )
        ),

        div(
          class = "sim-compare-main",
          bslib::card(
            sim_card_header("table-list", "Metrics side by side", "One column per included run."),
            bslib::card_body(div(class = "sim-compare-table-wrap", uiOutput(ns("metrics_table"))))
          ),
          bslib::card(
            sim_card_header("chart-line", "Losses by return period",
                            "Loss exceeded once in the given number of periods, up to a tenth of the smallest run."),
            bslib::card_body(
              div(class = "sim-compare-chart", plotly::plotlyOutput(ns("return_period_chart"), height = "400px"))
            )
          ),
          div(
            class = "sim-compare-note",
            icon("circle-info"),
            tags$span(
              "Results differ between runs partly because of simulation noise, ",
              "unless the runs use the same custom seed. Differences at long return periods are the noisiest."
            )
          )
        )
      )
    )
  )
}

#' Server of the Compare tab
#'
#' Stores the latest runs of the session (up to \code{max_runs}) with their metrics,
#' computed once each with summarise_simulation, and compares the included runs.
#'
#' @param id Module id.
#' @param last_run Reactive holding NULL, or list(id, settings, data, finished) of the latest run.
#' @param max_runs Number of runs kept; the oldest is dropped beyond it.
#' @param default_included Number of latest runs included by default.
#' @noRd
sim_compare_tab_server <- function(id, last_run, max_runs = 6, default_included = 3) {
  moduleServer(id, function(input, output, session) {
    ns <- session$ns

    #stored runs in the order they were added (see sim_tab_compare_entry)
    runs <- reactiveVal(list())
    #per-run state keyed by run id: display name, included flag, and whether the user
    #chose the included flag (an automatic default may only change untouched runs)
    state <- reactiveValues(name = list(), include = list(), touched = list())
    #observers that copy each run's inputs into the state, destroyed with the run
    handles <- new.env(parent = emptyenv())

    run_ids <- function(entries) vapply(entries, function(e) as.numeric(e$id), numeric(1))

    drop_runs <- function(ids) {
      if (length(ids) == 0) return(invisible(NULL))
      for (key in as.character(ids)) {
        if (!is.null(handles[[key]])) {
          lapply(handles[[key]], function(h) h$destroy())
          rm(list = key, envir = handles)
        }
        state$name[[key]] <- NULL
        state$include[[key]] <- NULL
        state$touched[[key]] <- NULL
      }
      current <- runs()
      runs(current[!(run_ids(current) %in% ids)])
    }

    watch_inputs <- function(id) {
      key <- as.character(id)
      include_id <- paste0("include_", id)
      name_id <- paste0("name_", id)
      handles[[key]] <- list(
        observe({
          value <- input[[include_id]]
          if (is.null(value)) return(NULL)
          isolate({
            #a value that already matches is the browser showing the state, not a choice
            if (!identical(isTRUE(value), isTRUE(state$include[[key]]))) {
              state$include[[key]] <- isTRUE(value)
              state$touched[[key]] <- TRUE
            }
          })
        }),
        observe({
          value <- input[[name_id]]
          if (is.null(value)) return(NULL)
          isolate({
            if (!identical(value, state$name[[key]])) state$name[[key]] <- value
          })
        })
      )
    }

    #store every new run once, with its metrics
    observeEvent(last_run(), {
      run <- last_run()
      if (is.null(run$id) || is.null(run$data)) return(NULL)
      current <- runs()
      if (as.numeric(run$id) %in% run_ids(current)) return(NULL)

      entry <- tryCatch(
        sim_tab_compare_entry(run),
        error = function(cond) {
          showNotification(
            paste("The run could not be added to the comparison:", conditionMessage(cond)),
            type = "error",
            duration = NULL
          )
          NULL
        }
      )
      if (is.null(entry)) return(NULL)

      key <- as.character(run$id)
      state$name[[key]] <- entry$name
      state$include[[key]] <- TRUE
      state$touched[[key]] <- FALSE
      watch_inputs(run$id)

      current <- c(current, list(entry))
      #drop the oldest beyond the limit
      if (length(current) > max_runs) {
        dropped <- run_ids(current)[seq_len(length(current) - max_runs)]
        drop_runs(dropped)
        current <- current[!(run_ids(current) %in% dropped)]
      }
      #by default only the latest few runs are included; runs the user has chosen keep their choice
      ids <- run_ids(current)
      older <- ids[seq_len(max(0, length(ids) - default_included))]
      for (old_key in as.character(older)) {
        if (!isTRUE(state$touched[[old_key]])) state$include[[old_key]] <- FALSE
      }
      runs(current)
    })

    observeEvent(input$remove, {
      id <- suppressWarnings(as.numeric(input$remove))
      if (length(id) == 1 && !is.na(id)) drop_runs(id)
    })

    observeEvent(input$clear_all, {
      drop_runs(run_ids(runs()))
    })

    display_name <- function(entry) {
      name <- state$name[[as.character(entry$id)]]
      if (is.null(name) || !nzchar(trimws(name))) entry$name else trimws(name)
    }

    included <- reactive({
      Filter(function(e) isTRUE(state$include[[as.character(e$id)]]), runs())
    })

    included_names <- reactive({
      vapply(included(), display_name, character(1))
    })

    #renaming re-draws the chart, so wait for typing to pause
    chart_inputs <- debounce(reactive(list(entries = included(), names = included_names())), 300)

    #a plain output function (no render wrapper) sends a bare TRUE/FALSE to the conditionalPanel
    output$has_comparison <- function(shinysession, name, ...) {
      length(runs()) >= 2
    }
    outputOptions(output, "has_comparison", suspendWhenHidden = FALSE)

    output$stored_note <- renderText({
      count <- length(runs())
      if (count == 0) {
        paste0("Every run is stored here (up to ", max_runs, ") so its results can be compared.")
      } else {
        paste0(count, " of up to ", max_runs, " runs stored. Older runs are dropped as new ones are added.")
      }
    })

    output$empty_state <- renderUI({
      current <- runs()
      if (length(current) >= 2) return(NULL)
      if (length(current) == 0) {
        sim_tab_empty_state(
          "scale-balanced", "Nothing to compare yet",
          paste0("Each simulation run is stored in this tab (up to ", max_runs, " runs). ",
                 "Comparisons appear from the second run."),
          sim_tab_open_simulator_button()
        )
      } else {
        sim_tab_empty_state(
          "scale-balanced", "One run stored",
          "The comparison appears once a second run has finished. Change the settings and run again.",
          div(class = "sim-run-desc", paste0(display_name(current[[1]]), ": ", current[[1]]$description)),
          sim_tab_open_simulator_button()
        )
      }
    })

    output$run_list <- renderUI({
      current <- runs()
      if (length(current) == 0) return(NULL)
      div(
        class = "sim-run-list",
        lapply(current, function(entry) {
          key <- as.character(entry$id)
          div(
            class = "sim-run-item",
            div(
              class = "sim-run-item-top",
              tags$span(class = "sim-run-id", paste("Run", entry$id)),
              div(
                class = "sim-run-name-input",
                textInput(ns(paste0("name_", entry$id)), label = NULL,
                          value = isolate(state$name[[key]]), placeholder = entry$name)
              ),
              tags$button(
                type = "button",
                class = "btn btn-sm btn-outline-secondary sim-run-remove",
                title = "Remove this run",
                `aria-label` = paste("Remove run", entry$id),
                onclick = sprintf("Shiny.setInputValue('%s', %s, {priority: 'event'});", ns("remove"), entry$id),
                icon("xmark")
              )
            ),
            div(class = "sim-run-desc", entry$description),
            div(
              class = "mt-2",
              bslib::input_switch(ns(paste0("include_", entry$id)), "Include in comparison",
                                  value = isTRUE(isolate(state$include[[key]])))
            )
          )
        })
      )
    })

    output$metrics_table <- renderUI({
      entries <- included()
      if (length(entries) == 0) {
        return(p(class = "sim-muted mb-0", "Include at least one run to see its metrics."))
      }
      sim_tab_metrics_table(entries, included_names())
    })

    output$return_period_chart <- plotly::renderPlotly({
      inputs <- chart_inputs()
      validate(need(length(inputs$entries) > 0, "Include at least one run to draw the chart."))
      max_rp <- min(vapply(inputs$entries, function(e) e$metrics$n, numeric(1))) / 10
      validate(need(max_rp >= 2, "The chart needs runs of at least 20 simulations."))
      sim_tab_return_period_plot(inputs$entries, inputs$names, dark = identical(input$app_theme, "dark"))
    })
  })
}

Try the NetSimR package in your browser

Any scripts or data that you put into this service are public.

NetSimR documentation built on Sept. 14, 2026, 1:07 a.m.