dot.plotly <- function(
  cats,                  # character vector: one category label per point
  vals,                  # numeric vector: the value for each category
  orientation = "v",     # "v" = vertical (cats on x-axis)
                         # "h" = horizontal (cats on y-axis)
  fill, border, shape, pt.size,
  x.lab = "",            # x-axis label
  y.lab = "",            # y-axis label
  digits_d = 2,
  pt_opacity = 0.95,
  gridT = NULL,          # tick positions for grid lines on the value axis
  origin_x = NULL,       # x-axis origin; NULL = auto (from gridT min)
  height = NULL,         # plot height in inches; NULL lets plotly auto-size
  facet = NULL,          # factor vector: one facet level per observation
  facet_name = NULL,     # display name for the facet variable
  n_row = NULL,          # facet grid rows (NULL = auto)
  n_col = NULL,          # facet grid columns (NULL = auto)
  stat = NULL,           # aggregation stat for faceted path ("mean","sum",etc.)
  sort = "0",            # sort order: "0"=none, "+"=asc, "-"=desc
  main = NULL,           # chart title (faceted path only)
  segments_x = TRUE,     # draw segment from origin to each dot (horizontal orientation)
  segments_y = TRUE      # draw segment from origin to each dot (vertical orientation)
) {

  # ---- paired dot plot (vals is a data frame with 2+ columns) -------------
  if (is.data.frame(vals)) {
    col_names     <- names(vals)
    n_series      <- ncol(vals)
    seg_color_hex <- .to_hex(getOption("segment_color"))
    border_hex    <- .to_hex(border[1])
    px            <- as.numeric(pt.size) * 7.25
    if (!is.finite(px) || px <= 0) px <- 7.25
    cats_all      <- as.character(cats)

    plt <- plotly::plot_ly()
    for (si in seq_len(n_series)) {
      vals_si <- as.numeric(vals[[si]])
      clr     <- .maketrans_plotly(fill[si], alpha = pt_opacity)
      if (isTRUE(segments_x)) {
        seg_x   <- as.vector(rbind(rep(origin_x, length(cats_all)), vals_si, NA_real_))
        seg_y   <- as.vector(rbind(cats_all, cats_all, NA))
        plt <- plotly::add_trace(plt, type="scatter", mode="lines",
          x=seg_x, y=seg_y,
          line=list(color=seg_color_hex, width=1),
          hoverinfo="skip", showlegend=FALSE, inherit=FALSE)
      }
      plt <- plotly::add_trace(plt, type="scatter", mode="markers",
        x=vals_si, y=cats_all,
        name=col_names[si],
        marker=list(symbol="circle",
          size     = rep(px, length(vals_si)),
          sizemode = "diameter",
          color    = clr, opacity = 1,
          line     = list(color=border_hex, width=1)),
        showlegend=TRUE, inherit=FALSE)
    }

    step    <- if (!is.null(gridT) && length(gridT) > 1L) gridT[2L] - gridT[1L] else 0
    x_range <- c(min(origin_x, min(gridT)), max(gridT) + step)
    x_ax    <- axis_num(x.lab, gridT, NULL)
    x_ax$range     <- x_range
    x_ax$autorange <- FALSE

    axis_x_clr    <- getOption("axis_x_color")
    if (is.null(axis_x_clr)) axis_x_clr <- getOption("axis_color")
    axis_y_clr    <- getOption("axis_y_color")
    if (is.null(axis_y_clr)) axis_y_clr <- getOption("axis_color")
    axis_text_clr <- getOption("axis_text_color")

    y_axis_cat <- c(
      axis_cat(y.lab),
      list(
        categoryorder = "array",
        categoryarray = cats_all,
        tickmode      = "array",
        tickvals      = cats_all,
        ticktext      = cats_all
      )
    )

    plt <- plotly::layout(plt,
      xaxis  = x_ax,
      yaxis  = y_axis_cat,
      shapes   = c(x_grid(gridT), plot_border()),
      margin   = list(t = if (!is.null(main)) 50 else 30),
      title    = if (!is.null(main)) list(
                   text    = main,
                   x       = 0.5, xanchor = "center",
                   y       = 0.98, yanchor = "top",
                   font    = list(size = 15)
                 ) else NULL,
      template = NULL
    )
    x_ax$linecolor  <- .to_hex(axis_x_clr)
    x_ax$tickcolor  <- .to_hex(axis_x_clr)
    x_ax$tickfont   <- list(color = .to_hex(axis_text_clr))
    plt$x$layout$xaxis <- x_ax
    plt$x$layout$plot_bgcolor  <- .to_hex(getOption("panel_fill",  "white"))
    plt$x$layout$paper_bgcolor <- .to_hex(getOption("window_fill", "white"))

    plt <- .finalize_plotly_widget(plt, kind="sp",
      x.name=x.lab, by.name="",
      add_title=FALSE,
      nudge_viewer=(.allow.interactive() &&
                    !isTRUE(getOption("knitr.in.progress"))))

    return(invisible(plt))
  }

  # ---- faceted dot plot ---------------------------------------------------
  if (!is.null(facet)) {
    fac_char   <- as.character(facet)
    fac_levels <- sort(unique(fac_char[!is.na(fac_char)]))
    n_fac      <- length(fac_levels)

    axis_x_clr    <- getOption("axis_x_color") %||% getOption("axis_color")
    axis_y_clr    <- getOption("axis_y_color") %||% getOption("axis_color")
    axis_text_clr <- getOption("axis_text_color")

    step    <- if (!is.null(gridT) && length(gridT) > 1L) gridT[2L] - gridT[1L] else 0
    global_min <- min(vals[is.finite(vals)], na.rm=TRUE)
    origin_floor <- if (!is.null(origin_x)) min(origin_x, global_min) else 0L
    x_range <- if (!is.null(gridT))
      c(min(origin_floor, min(gridT)), max(gridT) + step)
    else NULL

    px         <- as.numeric(pt.size) * 7.25
    if (!is.finite(px) || px <= 0) px <- 5
    marker_col <- .maketrans_plotly(fill[1], alpha = pt_opacity)
    border_hex <- .to_hex(border[1])
    seg_color  <- .to_hex(getOption("segment_color"))

    symbol <- switch(as.character(shape),
      "21"="circle","22"="square","23"="diamond",
      "24"="triangle-up","25"="triangle-down","circle"
    )

    val_fmt  <- .get.tick.fmt(vals[is.finite(vals)], digits_d)
    val_spec <- if (nzchar(val_fmt)) paste0(":", val_fmt) else ""
    hover_tmpl <- paste0(
      if (nzchar(y.lab)) y.lab else "Category", ": %{y}<br>",
      if (nzchar(x.lab)) x.lab else "Value",    ": %{x", val_spec, "}",
      "<extra></extra>"
    )

    panels <- vector("list", n_fac)
    for (i in seq_along(fac_levels)) {
      fac_lab    <- fac_levels[i]
      keep       <- fac_char == fac_lab & !is.na(fac_char)
      cats_i_raw <- as.character(cats[keep])
      vals_i_raw <- as.numeric(vals[keep])

      if (!is.null(stat) && length(cats_i_raw) > 0) {
        stat_fn <- switch(tolower(stat),
          mean   = function(z) mean(z,         na.rm=TRUE),
          sum    = function(z) sum(z,           na.rm=TRUE),
          median = function(z) stats::median(z, na.rm=TRUE),
          min    = function(z) min(z,           na.rm=TRUE),
          max    = function(z) max(z,           na.rm=TRUE),
          sd     = function(z) stats::sd(z,     na.rm=TRUE),
          function(z) sum(z, na.rm=TRUE)
        )
        agg    <- tapply(vals_i_raw, cats_i_raw, stat_fn)
        cats_i <- names(agg)
        vals_i <- as.numeric(agg)
      } else {
        cats_i <- cats_i_raw
        vals_i <- vals_i_raw
      }

      if (sort != "0" && length(cats_i) == length(vals_i)) {
        ord    <- order(vals_i, decreasing = (sort == "-"), na.last = TRUE)
        cats_i <- cats_i[ord]
        vals_i <- vals_i[ord]
      }

      pnl <- plotly::plot_ly()

      fin <- is.finite(vals_i)
      draw_seg <- if (orientation == "h") isTRUE(segments_x) else isTRUE(segments_y)
      if (any(fin) && draw_seg) {
        # seg_start: use origin_x if provided, but never higher than panel min
        # so no dot ends up with a backward segment
        panel_min <- min(vals_i[fin], na.rm=TRUE)
        seg_start <- if (!is.null(origin_x)) min(origin_x, panel_min) else 0L
        seg_x <- as.vector(rbind(rep(seg_start, sum(fin)), vals_i[fin], NA_real_))
        seg_y <- as.vector(rbind(cats_i[fin], cats_i[fin], NA))
        pnl <- plotly::add_trace(pnl, type="scatter", mode="lines",
          x=seg_x, y=seg_y,
          line=list(color=seg_color, width=1),
          hoverinfo="skip", showlegend=FALSE, inherit=FALSE)
      }

      pnl <- plotly::add_trace(pnl, type="scatter", mode="markers",
        x=vals_i, y=cats_i,
        marker=list(symbol=symbol,
          size=rep(px, length(vals_i)), sizemode="diameter",
          color=marker_col, opacity=1,
          line=list(color=border_hex, width=1)),
        hovertemplate=hover_tmpl,
        showlegend=FALSE, inherit=FALSE)

      val_ax <- axis_num(if (i == 1L) x.lab else "", gridT, NULL)
      if (!is.null(x_range)) {
        val_ax$range     <- x_range
        val_ax$autorange <- FALSE
      }
      val_ax$linecolor <- .to_hex(axis_x_clr)
      val_ax$tickcolor <- .to_hex(axis_x_clr)
      val_ax$tickfont  <- list(color = .to_hex(axis_text_clr))

      cat_ax <- c(
        axis_cat(y.lab),
        list(
          categoryorder = "array",
          categoryarray = cats_i,
          tickmode      = "array",
          tickvals      = cats_i,
          ticktext      = cats_i
        )
      )

      pnl <- plotly::layout(pnl, xaxis=val_ax, yaxis=cat_ax, template=NULL)
      pnl$x$layout$plot_bgcolor  <- .to_hex(getOption("panel_fill",  "white"))
      pnl$x$layout$paper_bgcolor <- .to_hex(getOption("window_fill", "white"))

      panels[[i]] <- pnl
    }

    n_col_use <- n_col %||% min(n_fac, 3L)
    n_row_use <- ceiling(n_fac / n_col_use)

    plt <- plotly::subplot(panels,
      nrows  = n_row_use, shareX = TRUE, shareY = FALSE,
      titleX = TRUE, titleY = FALSE, margin = 0.06)

    # extract domains that subplot() assigned, build facet title annotations
    ann <- lapply(seq_along(fac_levels), function(i) {
      sfx <- if (i == 1L) "" else as.character(i)
      xax <- plt$x$layout[[paste0("xaxis", sfx)]]
      yax <- plt$x$layout[[paste0("yaxis", sfx)]]
      xdom <- xax$domain %||% c(0, 1)
      ydom <- yax$domain %||% c(0, 1)
      list(
        text      = if (!is.null(facet_name) && nzchar(facet_name))
                      paste0(facet_name, ": ", fac_levels[i])
                    else fac_levels[i],
        x         = (xdom[1] + xdom[2]) / 2, xref = "paper", xanchor = "center",
        y         = min(ydom[2] + 0.04, 0.99), yref = "paper", yanchor = "bottom",
        showarrow = FALSE,
        font      = list(size = 13, family = "Arial")
      )
    })

    plt <- plotly::layout(plt,
      annotations = ann,
      margin      = list(t=if (!is.null(main)) 50 else 40, b=8, l=20, r=20),
      showlegend  = FALSE,
      title       = if (!is.null(main)) list(
                      text    = main,
                      x       = 0.5, xanchor = "center",
                      y       = 0.98, yanchor = "top",
                      font    = list(size = 15)
                    ) else NULL,
      paper_bgcolor = .to_hex(getOption("window_fill", "white"))
    )

    plt <- .finalize_plotly_widget(plt, kind="sp",
      x.name=x.lab, by.name="",
      add_title=FALSE,
      nudge_viewer=(.allow.interactive() &&
                    !isTRUE(getOption("knitr.in.progress"))))

    return(invisible(plt))
  }

  pt_opacity <- max(0, min(1, as.numeric(pt_opacity[1])))
  if (!is.finite(pt_opacity)) pt_opacity <- 0.95

  cats <- as.character(cats)
  vals <- as.numeric(vals)

  # align lengths: ax.info$axL* may carry extra padding ticks beyond the data
  cats <- cats[seq_along(vals)]

  # Keep all categories on the axis — including those with NA vals — so that
  # e.g. row_names with missing y shows all names, just no dot for missing.
  # Remove only rows where the category label itself is NA.
  keep_cats <- !is.na(cats)
  cats <- cats[keep_cats]
  vals <- vals[keep_cats]

  cats_all <- cats   # full list for categoryarray

  n <- length(cats)
  if (n == 0L) return(plotly::plot_ly())

  # ---- marker style ------------------------------------------------------
  symbol <- switch(as.character(shape),
    "21"="circle","22"="square","23"="diamond",
    "24"="triangle-up","25"="triangle-down","circle"
  )
  px           <- as.numeric(pt.size) * 7.25
  if (!is.finite(px) || px <= 0) px <- 5
  marker_color <- .maketrans_plotly(fill[1], alpha = pt_opacity)
  border_hex   <- .to_hex(border[1])
  seg_color    <- .to_hex(getOption("segment_color"))

  # ---- segment helper ----------------------------------------------------
  # One NA-separated lines trace covers all n segments in a single add_trace.
  # Vertical ("v"):   x fixed at category label, y runs 0 → val
  # Horizontal ("h"): y fixed at category label, x runs 0 → val

  .segments <- function(plt, cats, vals, seg_color, orientation, origin_x) {
    # only draw segments where vals is finite
    fin <- is.finite(vals)
    cats_fin <- cats[fin]
    vals_fin <- vals[fin]
    nf <- length(cats_fin)
    if (nf == 0L) return(plt)
    seg_start <- if (!is.null(origin_x)) origin_x else 0L
    if (orientation == "v") {
      seg_x <- as.vector(rbind(cats_fin,              cats_fin,    NA))
      seg_y <- as.vector(rbind(rep(seg_start, nf),   vals_fin,    NA_real_))
    } else {
      seg_x <- as.vector(rbind(rep(seg_start, nf),   vals_fin,    NA_real_))
      seg_y <- as.vector(rbind(cats_fin,              cats_fin,    NA))
    }
    plotly::add_trace(
      plt,
      type       = "scatter", mode = "lines",
      x          = seg_x, y = seg_y,
      line       = list(color = seg_color, width = 1),
      hoverinfo  = "skip",
      showlegend = FALSE,
      inherit    = FALSE
    )
  }

  # ---- hover template ----------------------------------------------------
  val_fmt  <- .get.tick.fmt(vals, digits_d)
  val_spec <- if (nzchar(val_fmt)) paste0(":", val_fmt) else ""

  if (orientation == "v") {
    cat_label  <- if (nzchar(x.lab)) x.lab else "Category"
    val_label  <- if (nzchar(y.lab)) y.lab else "Value"
    hover_tmpl <- paste0(
      cat_label, ": %{x}<br>",
      val_label, ": %{y", val_spec, "}",
      "<extra></extra>"
    )
  } else {
    cat_label  <- if (nzchar(y.lab)) y.lab else "Category"
    val_label  <- if (nzchar(x.lab)) x.lab else "Value"
    hover_tmpl <- paste0(
      val_label, ": %{x", val_spec, "}<br>",
      cat_label, ": %{y}",
      "<extra></extra>"
    )
  }

  # ---- build plot --------------------------------------------------------
  plt <- plotly::plot_ly()

  # segments first so points sit on top
  draw_seg <- if (orientation == "h") isTRUE(segments_x) else isTRUE(segments_y)
  if (draw_seg)
    plt <- .segments(plt, cats, vals, seg_color, orientation, origin_x)

  # points
  if (orientation == "v") {
    plt <- plotly::add_trace(
      plt,
      type = "scatter", mode = "markers",
      x = cats, y = vals,
      marker = list(
        symbol   = symbol,
        size     = rep(px, length(vals)),
        sizemode = "diameter",
        color    = marker_color,
        opacity  = 1,
        line     = list(color = border_hex, width = 1)
      ),
      hovertemplate = hover_tmpl,
      showlegend    = FALSE
    )
  } else {
    plt <- plotly::add_trace(
      plt,
      type = "scatter", mode = "markers",
      x = vals, y = cats,
      marker = list(
        symbol   = symbol,
        size     = rep(px, length(vals)),
        sizemode = "diameter",
        color    = marker_color,
        opacity  = 1,
        line     = list(color = border_hex, width = 1)
      ),
      hovertemplate = hover_tmpl,
      showlegend    = FALSE
    )
  }

  # ---- axes --------------------------------------------------------------
  # cat_axis: plotly type="category"; categoryarray fixes the order.
  #   showticklabels + showline are explicit to prevent plotly defaulting off.
  # val_axis: numeric axis via axis_num; grid lines come from gridT.

  # grid lines run perpendicular to the value axis:
  #   vertical dot plot   → horizontal grid lines → y_grid(gridT)
  #   horizontal dot plot → vertical   grid lines → x_grid(gridT)
  grid_shapes <- if (!is.null(gridT)) {
    if (orientation == "v") y_grid(gridT) else x_grid(gridT)
  } else {
    list()
  }

  # build axes inline — same pattern as bc_plotly.R which correctly renders
  # axis titles. storing in a variable and patching afterward does not work.
  cat_ax_list <- c(
    axis_cat(if (orientation == "v") x.lab else y.lab),
    list(
      categoryorder = "array",
      categoryarray = cats_all,
      tickmode      = "array",
      tickvals      = cats_all,
      ticktext      = cats_all
    )
  )

  if (orientation == "h") {
    # horizontal: cats on y, values on x
    val_range <- if (!is.null(origin_x) && !is.null(gridT)) {
      step <- if (length(gridT) > 1L) gridT[2L] - gridT[1L] else 0
      c(min(origin_x, min(gridT)), max(gridT) + step)
    } else NULL

    x_num <- axis_num(x.lab, gridT, NULL)
    if (!is.null(val_range)) { x_num$range <- val_range; x_num$autorange <- FALSE }

    plt <- plotly::layout(plt,
      xaxis    = x_num,
      yaxis    = cat_ax_list,
      shapes   = c(grid_shapes, plot_border()),
      margin = list(l = 70, t = if (!is.null(main)) 50 else 30
      ),
      title    = if (!is.null(main)) list(
                   text = main, x = 0.5, xanchor = "center",
                   y = 0.98, yanchor = "top", font = list(size = 15)
                 ) else NULL,
      template = NULL
    )
  } else {
    # vertical: cats on x, values on y
    val_range <- if (!is.null(origin_x) && !is.null(gridT)) {
      step <- if (length(gridT) > 1L) gridT[2L] - gridT[1L] else 0
      c(min(origin_x, min(gridT)), max(gridT) + step)
    } else NULL

    y_num <- axis_num(y.lab, gridT, NULL)
    if (!is.null(val_range)) { y_num$range <- val_range; y_num$autorange <- FALSE }

    plt <- plotly::layout(plt,
      xaxis    = cat_ax_list,
      yaxis    = y_num,
      shapes   = c(grid_shapes, plot_border()),
      margin   = list(t = if (!is.null(main)) 50 else 30, b=12),
      title    = if (!is.null(main)) list(
                   text = main, x = 0.5, xanchor = "center",
                   y = 0.98, yanchor = "top", font = list(size = 15)
                 ) else NULL,
      template = NULL
    )
  }

  if (!is.null(height)) plt$height <- as.integer(height * 96L)

  plt$x$layout$plot_bgcolor  <- .to_hex(getOption("panel_fill",  "white"))
  plt$x$layout$paper_bgcolor <- .to_hex(getOption("window_fill", "white"))

  if (is.null(plt$x$layout$margin)) plt$x$layout$margin <- list()
  b_default <- plt$x$layout$margin$b %||% 40
  plt$x$layout$margin$b <- max(b_default, if (nzchar(x.lab)) 60 else 0)

  plt <- .finalize_plotly_widget(
    plt,
    kind         = "sp",
    x.name       = x.lab,
    by.name      = "",
    add_title    = FALSE,
    nudge_viewer = TRUE
  )

  invisible(plt)
}
