dn.plotly <- function(
  x, by = NULL,              # numeric x; optional grouping var
  x_name, by_name = NULL,    # names for finalize/hover/legend
  fill,                      # scalar or length-G color(s)
  x_lab, y_lab = "Density",  # axis labels
  main = NULL,
  ax, gridT1 = NULL, gridT2 = NULL,  # lessR axis helpers
  # KDE controls (bandwidth externally supplied)
  bw, adjust = 1,
  kernel = "gaussian",
  n = 512, from = NULL, to = NULL,
  full_curve = TRUE,
  fill_area = TRUE,          # TRUE: translucent fill; FALSE: lines only
  digits_d = 3,
  style_opts = .plotly_style()
) {


  # --- groups ------------------------------------------------------------
  if (is.null(by)) {
    groups <- "Series 1"; G <- 1L
  } else {
    if (is.factor(by)) groups <- levels(by) else groups <- unique(by[!is.na(by)])
    G <- length(groups)
  }

  # --- shared support ----------------------------------------------------
  xr <- range(x, finite = TRUE)
  if (full_curve) {
    all.ends <- numeric(0)
    for (g in seq_len(G)) {
      xg.tmp <- if (G == 1L) x else x[by == groups[g]]
      xg.tmp <- xg.tmp[is.finite(xg.tmp)]
      tmp <- stats::density(xg.tmp, bw = bw, adjust = adjust,
                            kernel = kernel, n = n)
      all.ends <- c(all.ends,
                    tmp$x[c(1L, length(tmp$x))])
    }
    if (is.null(from) || is.na(from)) from <- min(all.ends)
    if (is.null(to)   || is.na(to))   to   <- max(all.ends)
  } else {
    if (is.null(from) || is.na(from)) from <- xr[1L]
    if (is.null(to)   || is.na(to))   to   <- xr[2L]
  }
  if (to <= from) to <- from + max(abs(from) * 1e-4, 1e-6)
  if (n < 2) n <- 2

  xgrid <- seq(from, to, length.out = n)
  dx    <- xgrid[2L] - xgrid[1L]

  # --- sanity: at least 2 finite obs per group ---------------------------
  if (G == 1L) {
    n.ok <- sum(is.finite(x))
    if (n.ok < 2L)
      stop("Density requires at least 2 finite observations;\n",
           "found ", n.ok, ".", call. = FALSE)
  } else {
    for (g in seq_len(G)) {
      xg <- x[by == groups[g]]
      n.ok <- sum(is.finite(xg))
      if (n.ok < 2L)
        stop("Group '", groups[g], "' has ", n.ok,
             " finite observation", ifelse(n.ok == 1, "", "s"),
             "; density requires at least 2.", call. = FALSE)
    }
  }

  # --- densities per group -----------------------------------------------
  dens.list <- vector("list", G)
  ymax <- 0
  for (g in seq_len(G)) {
    xg <- if (G == 1L) x else x[by == groups[g]]
    xg <- xg[is.finite(xg)]

    d  <- stats::density(
      xg, bw = bw, adjust = adjust, kernel = kernel,
      n = n, from = from, to = to
    )

    # align to shared grid
    if (length(d$x) != length(xgrid) || any(abs(d$x - xgrid) > 1e-12)) {
      d$y <- approx(d$x, d$y, xout = xgrid, rule = 2)$y
      d$x <- xgrid
    }

    # cumulative % via trapezoid (for hover)
    cum <- c(0, cumsum((d$y[-1] + d$y[-length(d$y)]) * 0.5 * dx))
    if (cum[length(cum)] > 0) cum <- cum / cum[length(cum)]

    dens.list[[g]] <- list(x = d$x, y = d$y, cum = cum,
                           mn = mean(xg))
    ymax <- max(ymax, d$y, na.rm = TRUE)
  }

  # --- ticks / labels ----------------------------------------------------
  gridT1 <- ax$axT1 %||% gridT1 %||% pretty(c(from, to), n = 5)
  gridL1 <- ax$axL1 %||% format(gridT1, big.mark = ",", trim = TRUE)
  gridT2 <- ax$axT2 %||% gridT2 %||% pretty(c(0, ymax), n = 6)
  gridL2 <- ax$axL2 %||% format(gridT2, trim = TRUE)

  # --- colors / alpha ----------------------------------------------------
  alpha.fill <- .auto_opacity(G, "fill")
  # no fill competing, so make the lines fully opaque for clarity
  alpha.line <- if (fill_area) .auto_opacity(G, "lines") else 1
  fill.rgba  <- .maketrans_plotly(rep_len(fill, G), alpha = alpha.fill)
  line.rgba  <- .maketrans_plotly(rep_len(fill, G), alpha = alpha.line)

  # --- hover builder -----------------------------------------------------
  .hover <- function(xx, yy, cpct) {
    paste0(
      x_name, ": ", format(signif(xx, 6)),
      "<br>Density=", format(round(yy, 6), trim = TRUE),

      "<br>Cumulative %: ", formatC(100 * cpct, format = "f", digits = 1), "%"
    )
  }

  # --- widget & traces ---------------------------------------------------
  plt <- plotly::plot_ly(
    type       = "scatter",
    mode       = "lines",
    showlegend = G > 1
    # elementId removed — belongs on the widget, not on the trace
  )

  for (g in seq_len(G)) {
    d  <- dens.list[[g]]
    nm <- if (G > 1) groups[g] else NULL
    plt <- plotly::add_trace(
      plt,
      x = d$x, y = d$y, name = nm,
      line      = list(color = line.rgba[g], width = 1.4),
      fill      = if (fill_area) "tozeroy" else "none",
      fillcolor = if (fill_area) fill.rgba[g] else NULL,
      hoverinfo = "text",
      hovertext = if (!is.null(nm) && length(by_name))
        paste0(by_name, ": ", groups[g], "<br>", .hover(d$x, d$y, d$cum))
      else
        .hover(d$x, d$y, d$cum)
    )
    mn   <- d$mn
    y.mn <- approx(d$x, d$y, xout = mn, rule = 2)$y
    mean.line <- list(color = line.rgba[g], dash = "dash",
                      width = if (fill_area) 1.4 else 0.7)
    plt  <- plotly::add_trace(
      plt,
      x          = c(mn, mn),
      y          = c(0, y.mn),
      showlegend = FALSE,
      line       = mean.line,
      hoverinfo  = "skip"
    )
  }

  # --- axes & grids ------------------------------------------------------
  border.shapes <- plot_border()
  x.shapes      <- x_grid(gridT1)

  # lines only (no fill): add an x-axis baseline in neutral gray
  axis.shapes <- if (!fill_area)
    list(list(type = "line", xref = "paper", yref = "paper",
              x0 = 0, x1 = 1, y0 = 0, y1 = 0,
              line = list(color = .to_hex("gray50"), width = 1)))
  else list()

  ax.x <- axis_num(x_lab, ax$axT1, ax$axL1)
  ax.y <- axis_num(y_lab, ax$axT2, ax$axL2)

  ax.x$tickmode <- "array"; ax.x$tickvals <- gridT1; ax.x$ticktext <- gridL1
  ax.y$tickmode <- "array"; ax.y$tickvals <- gridT2; ax.y$ticktext <- gridL2
  ax.y$showgrid  <- TRUE
  ax.y$gridcolor <- .to_hex(style_opts$grid_col)
  ax.y$gridwidth <- 1
  ax.y$griddash  <- "dot"

  # nudge x-axis title up a bit (keeps font size unchanged)
  if (is.character(ax.x$title)) {
    ax.x$title <- list(text = ax.x$title, standoff = 10)
  } else if (is.list(ax.x$title)) {
    ax.x$title$standoff <- 10
  }
  ax.x$automargin <- TRUE

  plt <- plotly::layout(
    plt,
    xaxis = ax.x,
    yaxis = ax.y,
    shapes = c(x.shapes, border.shapes, axis.shapes),
    template = NULL
  )


  # --- background & legend -----------------------------------------------
  plt$x$layout$plot_bgcolor  <- .to_hex(style_opts$panel_fill)
  plt$x$layout$paper_bgcolor <- .to_hex(style_opts$window_fill)

  if (G > 1) {
    leg.border <- .to_hex(style_opts$legend_border)
    leg.bg     <- .to_hex(style_opts$window_fill)
    plt <- plotly::layout(plt, legend = list(
      title = list(
        text = if (length(by_name)) by_name else "",
        font = list(size = round(
          15 * getOption("lab_size", 1)))),
      font = list(size = round(
        16 * getOption("axis_size", 0.9))),
      bgcolor = leg.bg,
      bordercolor = leg.border,
      borderwidth = 1
    ))
  }

  # --- finalize ----------------------------------------------------------
  plt <- .finalize_plotly_widget(
    plt,
    kind = "density",
    x_name = x_name,
    by_name = if (G > 1) by_name else NULL,
    add_title = FALSE,
    nudge_viewer = (.allow.interactive() && !isTRUE(getOption("knitr.in.progress")))
  )

  invisible(plt)
}
