R/MizerScan-class.R

Defines functions plotHover.MizerScan add_scan_annotations scan_plot_params select_scan_series prepare_MizerScan_plot_data plot.MizerScan `[.MizerScan` str.MizerScan as.data.frame.MizerScan print.summary.MizerScan summary.MizerScan print.MizerScan copy_scan_attributes set_scan_maximum label_with_units scan_x_label scan_y_label scan_y_var scan_x_var is.MizerScan MizerScan

Documented in add_scan_annotations copy_scan_attributes is.MizerScan label_with_units MizerScan plotHover.MizerScan plot.MizerScan prepare_MizerScan_plot_data scan_plot_params scan_x_label scan_x_var scan_y_label scan_y_var select_scan_series set_scan_maximum

# MizerScan S3 class for the result of a parameter scan
#
# Copyright 2026 Gustav Delius.
# Distributed under the GPL 3 or later.

#' S3 class for the result of a parameter scan
#'
#' `r lifecycle::badge("experimental")`
#' [scanModel()] varies one aspect of a model over a range of values and
#' measures a quantity on the attractor the model settles on at each of them.
#' It returns its result as a `MizerScan` object, which is a data frame
#' carrying, in addition, everything that [plot()] needs to draw it.
#'
#' A `MizerScan` object behaves like an ordinary data frame with one row for
#' each combination of scanned value and series. Its columns are, in order:
#' \describe{
#'   \item{1}{The scanned value. The column is named after the `scan_name`, so
#'     for example a scan over fishing effort has a column called
#'     `"Fishing effort"`. Use `names(scan)[[1]]` rather than hard-coding it.}
#'   \item{2}{The measured quantity, averaged over the attractor. Named after
#'     the `value_name`.}
#'   \item{`Species`}{The series the row refers to. Named `Species` whatever the
#'     series are, because that is the column that mizer's colour and line-type
#'     machinery reads.}
#'   \item{`ymin`, `ymax`}{The smallest and largest value over the sampling
#'     window. On a fixed point these both equal the value; on a limit cycle
#'     they give the range of the oscillation.}
#'   \item{`attractor`}{What the state reached at this scan value is:
#'     `"fixed_point"`, `"limit_cycle"` or `NA` for neither. This is the column
#'     that says whether the value in this row can be read as an equilibrium.}
#'   \item{`termination`, `converged`}{Why the run at this scan value stopped,
#'     and whether the solver met its own criterion. Both come from the
#'     `"convergence"` attribute that [projectUntilSettled()] attaches to its
#'     result, and neither is a claim about the state — see `attractor` for
#'     that.}
#'   \item{`period`}{The period of the limit cycle in years, or `NA`.}
#'   \item{`residual`}{How far the state still is from a fixed point, as a
#'     per-capita rate in 1/year, see [getSteadyResidual()].}
#' }
#'
#' The first three columns are the x, y and grouping variable in that order,
#' which is the layout [plotDataFrame()] expects.
#'
#' It also carries these attributes:
#' \itemize{
#'   \item `scan_name`, `scan_units` – name and units of the quantity that was
#'     varied, used for the x-axis label.
#'   \item `value_name`, `value_units` – name and units of the quantity that was
#'     measured, used for the y-axis label.
#'   \item `type` – the kind of quantity the values are, see [array_types].
#'   \item `params` – the `MizerParams` object the scan started from, used for
#'     species colours and line types.
#'   \item `reference_lines` – an optional named numeric vector of positions on
#'     the x axis to mark with vertical lines, for example
#'     `c(F_MSY = 0.32)`.
#'   \item `at_max`, `max_value` – for each series, the scanned value at which
#'     the measured quantity is largest, and the value it takes there. See the
#'     section below.
#'   \item `settings` – a list recording the settings the scan was run with.
#' }
#'
#' @section Where the maximum is:
#'
#' The `at_max` attribute holds, for each series, the scanned value at which
#' that series' measured quantity is largest. On a yield-versus-fishing-mortality
#' scan that is \eqn{F_{MSY}}; on a scan over fishing effort it is the effort
#' that maximises the quantity being plotted. `max_value` holds the value
#' attained there.
#'
#' This is the largest value **among those that were scanned**, not the maximum
#' of the underlying curve. It is therefore only as good as the grid you gave in
#' `scan_values`, and the way to sharpen it is to scan a finer grid near the
#' maximum, not to interpolate a coarse one. Subsetting a `MizerScan` with `[`
#' recomputes both attributes from the rows that remain, so they never go stale.
#'
#' @section Limitations:
#'
#' Because the object is a data frame subclass, its attributes survive base R
#' subsetting with `[` but are dropped by functions that rebuild the data frame,
#' including `dplyr::filter()`, `dplyr::mutate()`, `subset()` and `transform()`.
#' A scan that has lost its attributes can no longer be plotted. Subset with `[`,
#' or rebuild the object with `MizerScan()`.
#'
#' @param x A data frame with at least three columns, laid out as described
#'   above. For `is.MizerScan()`, any object to test.
#' @param scan_name A string naming the quantity that was varied.
#' @param scan_units A string giving its units, for example `"1/year"`.
#' @param value_name A string naming the quantity that was measured.
#' @param value_units A string giving its units, for example `"g/year"`.
#' @param type The kind of quantity the measured values are, see [array_types].
#' @param params The `MizerParams` object the scan started from.
#' @param reference_lines An optional named numeric vector of x positions to
#'   mark with vertical lines.
#' @param settings An optional list recording the settings used.
#'
#' @return A `MizerScan` object, which inherits from `data.frame`.
#'
#' @seealso [scanModel()], [plot.MizerScan()]
#' @family scan functions
#' @concept scan
#' @export
#' @examples
#' \donttest{
#' scan <- scanModel(NS_params, scan_values = c(0, 0.5, 1),
#'                   set_func = scanEffort(), species = "Cod")
#' scan
#' summary(scan)
#' attr(scan, "at_max")
#' }
MizerScan <- function(x, scan_name = NULL, scan_units = NULL,
                      value_name = NULL, value_units = NULL,
                      type = NULL, params = NULL,
                      reference_lines = NULL, settings = NULL) {
    if (!is.data.frame(x)) {
        stop("`x` must be a data frame.")
    }
    if (ncol(x) < 3) {
        stop("`x` must have at least three columns: the scanned value, the ",
             "measured value and the series.")
    }
    if (!("Species" %in% names(x))) {
        stop("`x` must have a `Species` column naming the series.")
    }
    # The type is resolved once and stored, so that array_type() and the
    # helpers built on it never need to consult the units attribute, which
    # this class calls `value_units` rather than `units`.
    type <- resolve_array_type(type, value_name, value_units)
    x <- structure(x,
        class = c("MizerScan", "data.frame"),
        scan_name = scan_name,
        scan_units = scan_units,
        value_name = value_name,
        value_units = value_units,
        type = type,
        params = params,
        reference_lines = reference_lines,
        settings = settings
    )
    set_scan_maximum(x)
}

#' @rdname MizerScan
#' @return `is.MizerScan()` returns `TRUE` if `x` is a `MizerScan` object,
#'   `FALSE` otherwise.
#' @export
is.MizerScan <- function(x) {
    inherits(x, "MizerScan")
}

# ---------------------------------------------------------------------------
# Internal accessors
#
# The first two column names are set from `scan_name` and `value_name` and are
# therefore user data. Nothing outside these two functions may hard-code them.
# ---------------------------------------------------------------------------

#' The x and y variables of a MizerScan
#'
#' @param x A MizerScan object.
#' @return The name of the column holding the scanned value / the measured
#'   value.
#' @keywords internal
scan_x_var <- function(x) names(x)[[1]]

#' @rdname scan_x_var
#' @keywords internal
scan_y_var <- function(x) names(x)[[2]]

#' Axis labels for a MizerScan
#'
#' Assembles `"<name> [<units>]"`, the same way `array_y_label()` does for the
#' array classes.
#'
#' @param x A MizerScan object.
#' @param default The label to use when the name is missing.
#' @return A string.
#' @keywords internal
scan_y_label <- function(x, default = "Value") {
    label_with_units(attr(x, "value_name") %||% default,
                     attr(x, "value_units"))
}

#' @rdname scan_y_label
#' @keywords internal
scan_x_label <- function(x, default = "Scan value") {
    label_with_units(attr(x, "scan_name") %||% default,
                     attr(x, "scan_units"))
}

#' @rdname scan_y_label
#' @param name The name of the quantity.
#' @param units The units, possibly NULL.
#' @keywords internal
label_with_units <- function(name, units) {
    if (!is.null(units) && nzchar(units)) {
        paste0(name, " [", units, "]")
    } else {
        name
    }
}

#' Record where each series attains its maximum
#'
#' Sets the `at_max` and `max_value` attributes from the rows currently in the
#' object, so that subsetting cannot leave a stale maximum behind.
#'
#' @param x A MizerScan object.
#' @return `x` with the two attributes set.
#' @keywords internal
set_scan_maximum <- function(x) {
    x_var <- scan_x_var(x)
    y_var <- scan_y_var(x)
    series <- unique(as.character(x[["Species"]]))
    at_max <- rep(NA_real_, length(series))
    max_value <- rep(NA_real_, length(series))
    names(at_max) <- names(max_value) <- series
    for (s in series) {
        rows <- which(as.character(x[["Species"]]) == s)
        y <- x[[y_var]][rows]
        if (all(is.na(y))) next
        best <- rows[[which.max(y)]]
        at_max[[s]] <- x[[x_var]][[best]]
        max_value[[s]] <- x[[y_var]][[best]]
    }
    attr(x, "at_max") <- at_max
    attr(x, "max_value") <- max_value
    x
}

# The metadata a MizerScan carries. Listed once, so that an attribute added to
# the class cannot be copied by `[` but left behind by `as.data.frame()`.
# `at_max` and `max_value` are derived from the rows and so are recomputed
# rather than copied, see set_scan_maximum().
scan_attributes <- c("scan_name", "scan_units", "value_name", "value_units",
                     "type", "params", "reference_lines", "settings")
scan_derived_attributes <- c("at_max", "max_value")

#' Copy the metadata of a MizerScan onto another object
#'
#' @param to The object to receive the attributes.
#' @param from The MizerScan to take them from.
#' @return `to` with the attributes and class of a MizerScan.
#' @keywords internal
copy_scan_attributes <- function(to, from) {
    for (a in scan_attributes) {
        attr(to, a) <- attr(from, a)
    }
    class(to) <- c("MizerScan", "data.frame")
    to
}

# ---------------------------------------------------------------------------
# Methods
# ---------------------------------------------------------------------------

#' @export
print.MizerScan <- function(x, ...) {
    header <- paste0(scan_y_label(x, default = "Value"), " vs ",
                     scan_x_label(x))
    cat(header, "\n")
    n_values <- length(unique(x[[scan_x_var(x)]]))
    n_series <- length(unique(as.character(x[["Species"]])))
    cat(n_values, "scan values x", n_series, "series\n")

    frame <- as.data.frame(x)
    n_show <- min(nrow(frame), mizer_print_defaults$rows_max)
    print(frame[seq_len(n_show), , drop = FALSE], row.names = FALSE)
    if (n_show < nrow(frame)) {
        cat("# ... ", nrow(frame) - n_show, " more rows\n", sep = "")
    }
    # Reported on the attractor rather than on `converged`: what matters to a
    # reader of the curve is which points are equilibria, not which runs
    # stopped tidily.
    unsettled <- unique(x[[scan_x_var(x)]][is.na(x$attractor)])
    if (length(unsettled) > 0) {
        cat("Did not settle at ", scan_x_var(x), " = ",
            paste(signif(unsettled, 3), collapse = ", "), "\n", sep = "")
    }
    invisible(x)
}

#' @export
summary.MizerScan <- function(object, ...) {
    x_var <- scan_x_var(object)
    y_var <- scan_y_var(object)
    at_max <- attr(object, "at_max")
    max_value <- attr(object, "max_value")
    series <- names(at_max)

    per_series <- data.frame(
        Species = series,
        Min = vapply(series, function(s) {
            min(object[[y_var]][as.character(object$Species) == s], na.rm = TRUE)
        }, numeric(1)),
        Max = unname(max_value[series]),
        at_max = unname(at_max[series]),
        row.names = NULL,
        stringsAsFactors = FALSE
    )

    result <- list(
        value_label = scan_y_label(object),
        scan_label = scan_x_label(object),
        x_var = x_var,
        n_values = length(unique(object[[x_var]])),
        range = range(object[[x_var]], na.rm = TRUE),
        per_series = per_series,
        attractor = table(object$attractor[!duplicated(object[[x_var]])],
                          useNA = "ifany"),
        settings = attr(object, "settings")
    )
    class(result) <- "summary.MizerScan"
    result
}

#' @export
print.summary.MizerScan <- function(x, ...) {
    cat(x$value_label, "vs", x$scan_label, "\n")
    cat(x$n_values, "scan values from", signif(x$range[[1]], 3), "to",
        signif(x$range[[2]], 3), "\n\n")
    print(x$per_series, row.names = FALSE)
    cat("\n`at_max` is the scanned value with the largest value, over the",
        "\nvalues that were scanned. Scan a finer grid to sharpen it.\n")
    if (length(x$attractor) > 0) {
        cat("\nAttractors reached:\n")
        print(x$attractor)
    }
    invisible(x)
}

#' @export
as.data.frame.MizerScan <- function(x, ...) {
    for (a in c(scan_attributes, scan_derived_attributes)) {
        attr(x, a) <- NULL
    }
    class(x) <- "data.frame"
    x
}

#' @export
str.MizerScan <- function(object, ...) {
    params <- attr(object, "params")
    attr(object, "params") <- NULL
    attr(object, "settings") <- NULL
    class(object) <- "data.frame"
    out <- utils::capture.output(utils::str(object, ...))
    out[1] <- paste0(" 'MizerScan' ", sub("^ ", "", out[1]))
    cat(paste0(out, collapse = "\n"), "\n", sep = "")
    if (!is.null(params)) {
        cat(" - attr(*, \"params\")=Formal class 'MizerParams' [package \"mizer\"] with ",
            length(slotNames(params)), " slots\n", sep = "")
    }
    invisible(NULL)
}

#' @export
`[.MizerScan` <- function(x, i, j, ..., drop = if (missing(i)) TRUE else FALSE) {
    old_names <- names(x)[1:3]
    result <- NextMethod()
    # `[.data.frame` keeps the class of a subclass whatever it does to the
    # contents, so the class has to be taken away again explicitly once the
    # result has stopped being a scan.
    still_a_scan <- is.data.frame(result) && ncol(result) >= 3 &&
        identical(names(result)[1:3], old_names)
    if (still_a_scan) {
        result <- copy_scan_attributes(result, x)
        # Recompute rather than copy, so a subset never carries a maximum
        # taken over rows it no longer contains.
        result <- set_scan_maximum(result)
    } else if (is.data.frame(result)) {
        result <- as.data.frame(result)
    }
    result
}

# ---------------------------------------------------------------------------
# Plotting
# ---------------------------------------------------------------------------

#' Plot method for `MizerScan` objects
#'
#' `r lifecycle::badge("experimental")`
#' Draws the result of a [scanModel()] run: the measured quantity against the
#' quantity that was scanned, with a band showing the range the quantity takes
#' over the attractor wherever that attractor is not a fixed point.
#'
#' A model that settles on a fixed point contributes a single value, so the band
#' has zero width there. A model that settles on a limit cycle contributes the
#' average over one period as the line and the range over that period as the
#' band, so a Hopf bifurcation shows up as the scan value at which the band
#' opens up.
#'
#' Scan values where the model reached neither a fixed point nor a limit cycle
#' within the time allowed are marked with a cross, because the value plotted
#' there is only an average over the last few years of a run that was still
#' changing.
#'
#' @param x A `MizerScan` object.
#' @param species The species to show. By default all series in the scan.
#' @param style One of `"ribbon"` (default: the average as a line inside the
#'   band), `"envelope"` (lines along the edges of the band, no average) or
#'   `"line"` (no band).
#' @param highlight Name or vector of names of the species to be highlighted
#'   with a thicker line.
#' @param log_x,log_y,log Whether to use logarithmic axes, see [parsePlotLog()].
#' @param xlim,ylim Numeric vectors of length two giving the axis limits. Use
#'   `NA` to refer to the existing minimum or maximum.
#' @param y_ticks The approximate number of ticks desired on the y axis.
#' @param reference_lines Whether to draw the reference lines stored in the
#'   scan, or a named numeric vector of x positions to draw instead.
#' @param mark_max Whether to mark, for each series, the scanned value at which
#'   the measured quantity is largest. See [MizerScan()].
#' @param show_unsettled Whether to mark the scan values where the model did not
#'   settle onto an attractor.
#' @param return_data Whether to return the data frame used for the plot instead
#'   of the plot itself.
#' @param ... Unused.
#'
#' @return A ggplot2 object, unless `return_data = TRUE`, in which case the data
#'   frame used for the plot is returned.
#' @family scan functions
#' @concept scan
#' @seealso [scanModel()], [MizerScan()], [plotting_functions]
#' @export
#' @examples
#' \donttest{
#' scan <- scanModel(NS_params, scan_values = seq(0, 1, 0.25),
#'                   set_func = scanEffort(), species = c("Cod", "Herring"))
#' plot(scan)
#' plot(scan, style = "envelope", mark_max = TRUE)
#' }
plot.MizerScan <- function(x, species = NULL,
                           style = c("ribbon", "envelope", "line"),
                           highlight = NULL,
                           log_x = FALSE, log_y = TRUE, log = NULL,
                           xlim = c(NA, NA), ylim = c(NA, NA), y_ticks = 6,
                           reference_lines = TRUE, mark_max = FALSE,
                           show_unsettled = TRUE, return_data = FALSE, ...) {
    style <- match.arg(style)
    log_y <- array_log_y(x, log_y, log, !missing(log_y))
    log_axes <- parsePlotLog(log, log_x = log_x, log_y = log_y)

    plot_dat <- prepare_MizerScan_plot_data(x, species = species)
    if (return_data) return(plot_dat)

    params <- scan_plot_params(x, plot_dat)
    p <- plotDataFrame(plot_dat, params, style = style,
                       xlab = scan_x_label(x), ylab = scan_y_label(x),
                       xtrans = if (log_axes$log_x) "log10" else "identity",
                       ytrans = if (log_axes$log_y) "log10" else "identity",
                       xlim = xlim,
                       ylim = array_ylim(x, ylim, log_axes$log_y, plot_dat[[2]]),
                       y_ticks = y_ticks, highlight = highlight)
    add_scan_annotations(p, x, plot_dat, reference_lines = reference_lines,
                         mark_max = mark_max,
                         show_unsettled = show_unsettled)
}

#' Prepare the data frame for plotting a MizerScan
#'
#' @param x A MizerScan object.
#' @param species The series to keep, or NULL for all of them.
#' @return A data frame with the x, y and grouping variable in the first three
#'   columns, as [plotDataFrame()] requires.
#' @keywords internal
prepare_MizerScan_plot_data <- function(x, species = NULL) {
    frame <- as.data.frame(x)
    if (!is.null(species)) {
        frame <- frame[select_scan_series(frame$Species, species), ,
                       drop = FALSE]
    }
    rownames(frame) <- NULL
    frame
}

#' Pick out some of the series of a scan
#'
#' The series of a scan need not be species: a scan of `getMeanWeight()` has a
#' single series that no model has ever heard of. So the selection is made
#' against the series the scan actually holds rather than through
#' [valid_species_arg()], which would reject anything that is not a species in
#' the model.
#'
#' @param available The `Species` column of the scan.
#' @param species The series asked for, as names, as whole-number indices into
#'   the series of the scan, or as a logical vector with one entry per series.
#'   Must select at least one.
#' @return A logical vector selecting the rows to keep.
#' @keywords internal
select_scan_series <- function(available, species) {
    available <- as.character(available)
    series <- unique(available)
    if (length(species) == 0) {
        stop("`species` selects no series at all. Leave it as NULL to keep ",
             "all of them.")
    }
    if (anyNA(species)) {
        stop("`species` must not contain NA.")
    }
    chosen <- if (is.logical(species)) {
        if (length(species) != length(series)) {
            stop("A logical `species` argument must have one entry for each ",
                 "of the ", length(series), " series of the scan, but it has ",
                 length(species), ".")
        }
        if (!any(species)) {
            stop("`species` selects no series at all. Leave it as NULL to ",
                 "keep all of them.")
        }
        series[species]
    } else if (is.numeric(species)) {
        # An index has to be a whole number in range. Without this check R
        # would quietly truncate 1.5 to 1 and return the wrong series.
        if (!all(is.finite(species)) ||
                !isTRUE(all(species == round(species)))) {
            stop("When `species` is numeric it indexes the series of the scan, ",
                 "so it must consist of whole numbers.")
        }
        bad <- species < 1 | species > length(series)
        if (any(bad)) {
            stop("This scan has ", length(series), " series, so the index ",
                 paste(species[bad], collapse = ", "), " is out of range.")
        }
        series[species]
    } else {
        as.character(species)
    }
    unknown <- setdiff(chosen, series)
    if (length(unknown) > 0) {
        stop("This scan has no series called ",
             paste0("`", unknown, "`", collapse = ", "),
             ". It has ", paste0("`", series, "`", collapse = ", "), ".")
    }
    available %in% chosen
}

#' The params object to use when plotting a MizerScan
#'
#' Series that are not species have no colour in the model, and
#' [plotDataFrame()] silently drops any legend level it cannot find a colour
#' for. So any such series is given a colour here, using the ordinary
#' [setColours()] interface, which also leaves the user free to choose a
#' different one.
#'
#' @param x A MizerScan object.
#' @param plot_dat The data frame that will be plotted.
#' @return A MizerParams object with a colour for every series in `plot_dat`.
#' @keywords internal
scan_plot_params <- function(x, plot_dat) {
    params <- attr(x, "params")
    if (is.null(params)) {
        stop("This scan has lost its `params` attribute, so it cannot be ",
             "plotted. Functions that rebuild a data frame, such as ",
             "dplyr::filter() or subset(), drop it. Subset with `[` instead, ",
             "or rebuild the object with MizerScan().")
    }
    series <- unique(as.character(plot_dat$Species))
    fill_in <- function(params, have, set, value) {
        missing <- setdiff(series, names(have(params)))
        if (length(missing) == 0) return(params)
        set(params, stats::setNames(as.list(value(missing)), missing))
    }
    params <- fill_in(params, getColours, setColours, function(missing) {
        utils::head(grDevices::hcl.colors(max(3, length(missing)), "Dark 3"),
                    length(missing))
    })
    # A line style is needed too, or the manual linetype scale has no level to
    # match and ggplot2 warns.
    fill_in(params, getLinetypes, setLinetypes,
            function(missing) rep("solid", length(missing)))
}

#' Add the annotation layers to a MizerScan plot
#'
#' @param p The ggplot object so far.
#' @param x The MizerScan object.
#' @param plot_dat The data frame being plotted.
#' @param reference_lines TRUE to use the stored reference lines, FALSE for
#'   none, or a named numeric vector to use instead.
#' @param mark_max Whether to mark where each series attains its maximum.
#' @param show_unsettled Whether to mark the scan values that did not settle.
#' @return The ggplot object with the extra layers, still a `mizer_plot`.
#' @keywords internal
add_scan_annotations <- function(p, x, plot_dat, reference_lines = TRUE,
                                 mark_max = FALSE, show_unsettled = TRUE) {
    tooltip <- attr(p, "mizer_tooltip")
    x_var <- scan_x_var(x)
    y_var <- scan_y_var(x)

    if (isTRUE(show_unsettled)) {
        # Base subsetting rather than subset() or dplyr::filter(), so that no
        # variable name has to be registered with globalVariables().
        bad <- plot_dat[is.na(plot_dat$attractor), , drop = FALSE]
        if (nrow(bad) > 0) {
            p <- p +
                geom_point(data = bad,
                           aes(x = .data[[x_var]], y = .data[[y_var]]),
                           shape = 4, size = 2, colour = "black",
                           inherit.aes = FALSE, show.legend = FALSE) +
                labs(caption = paste("x marks values where the model did not",
                                     "settle onto an attractor"))
        }
    }

    refs <- if (isTRUE(reference_lines)) {
        attr(x, "reference_lines")
    } else if (isFALSE(reference_lines)) {
        NULL
    } else {
        reference_lines
    }
    if (length(refs) > 0) {
        p <- p + geom_vline(xintercept = unname(refs), linetype = "dashed",
                            colour = "grey50")
        if (!is.null(names(refs)) && all(nzchar(names(refs)))) {
            y_scale <- p$scales$get_scales("y")
            is_log <- isTRUE(y_scale$trans$name == "log-10")
            y_bottom <- if (is_log) {
                min(plot_dat[[2]][plot_dat[[2]] > 0], na.rm = TRUE)
            } else {
                -Inf
            }
            ref_df <- data.frame(
                x = unname(refs),
                y = y_bottom,
                label = names(refs)
            )
            p <- p + ggrepel::geom_text_repel(
                data = ref_df,
                aes(x = .data[["x"]], y = .data[["y"]],
                    label = .data[["label"]]),
                hjust = -0.1,
                vjust = -0.5,
                direction = "y",
                size = 3,
                colour = "grey40",
                segment.color = "grey60",
                segment.size = 0.3,
                inherit.aes = FALSE,
                show.legend = FALSE,
                seed = 42
            )
        }
    }

    if (isTRUE(mark_max)) {
        at_max <- attr(x, "at_max")
        max_value <- attr(x, "max_value")
        keep <- names(at_max) %in% unique(as.character(plot_dat$Species))
        at_max <- at_max[keep]
        max_value <- max_value[keep]
        if (length(at_max) > 0 && any(!is.na(at_max))) {
            marks <- data.frame(xx = unname(at_max), yy = unname(max_value))
            marks <- marks[!is.na(marks$xx), , drop = FALSE]
            p <- p + geom_point(data = marks, aes(x = .data[["xx"]],
                                                  y = .data[["yy"]]),
                                shape = 1, size = 3, colour = "black",
                                inherit.aes = FALSE, show.legend = FALSE)
        }
    }

    make_mizer_plot(p, tooltip)
}

#' @rdname plotHover
#' @export
plotHover.MizerScan <- function(x, ...) {
    plotHover(plot(x, ...))
}

Try the mizer package in your browser

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

mizer documentation built on Aug. 24, 2026, 9:08 a.m.