.hier.plotly <- function(
  nd = NULL,               # pre-aggregated node table (non-faceted call)
  nd_byfac = NULL,         # named list of nd tables, one per facet level
  x_name = "X", by_name = "Group", facet_name = "Facet",
  type = "sunburst",
  stat_label = NULL,       # hover-text label for the statistic
  non_additive = FALSE,    # TRUE for mean/median/etc.; controls label fmt
  n_levels = 1L,           # depth: 1 = no by-grouping; 2+ = nested
  has_y    = TRUE,         # was y supplied? affects title layout & digits
  fill_vec = NULL,         # pre-resolved hex vec keyed by top.level (Chart)
  fill_vec_byfac = NULL,   # per-facet hex vec (Chart, faceted only)
  border = NULL,
  digits_d = NULL,
  facet_opts = .plotly_facet_opts(),
  main = NULL,
  title_x = NULL,
  show_labels = TRUE,
  labels = c("input", "%", "prop", "off"),
  labels_position = c("in", "out"),
  labels_color = "white",
  labels_size  = 0.75,
  ...
) {

  # local aliases for the dot-named internals used downstream
  non.additive <- isTRUE(non_additive)
  stat.label   <- stat_label

  title.size <- round(16 * getOption("main_size", 1))
  sub.size   <- round(13 * getOption("main_size", 1))


# add trace function ------------------------------------------------------

  make.trace <- function(p, nd, domain_xy) {
    if (non.additive && isTRUE(digits_d == 0)) digits_d <- digits_d + 2  # mean, etc.

    digits <- if (is.null(digits_d)) 2L else max(0L, as.integer(digits_d))
    fmt.spec <- paste0("%{customdata.stat:,.", digits, "f}")

    # Use texttemplate (accesses customdata on all nodes including parents with
    # values=0) rather than textinfo+text (treemaps suppress text on value=0 nodes).
    # "prop" also needs texttemplate to display raw proportion (not percentage).
    use.template <- show_text && (
      (non.additive && !identical(labels, "off")) ||
      identical(labels, "prop") ||
      identical(labels, "input")
    )

    txtinfo <- if (!show_text) "none"
               else switch(labels,
                 "input" = "none",   # value drawn via texttemplate below
                 "%"     = "label+percent root",
                 "prop"  = "none",   # handled by texttemplate below
                 "off"   = "label",  # label name only, no value/stat
                 "label"
               )

    texttemplate.str <- if (!use.template) NULL
      else if (identical(labels, "prop"))
        "%{label}<br>%{percentRoot:.3f}"                      # raw proportion
      else
        paste0("%{label}<br>", fmt.spec)                      # stat value

    stat.line <- paste0(
      stat.label, ": %{customdata.stat:,.", digits, "f}"
    )

    pct.parent.line <- if (non.additive) ""
                       else "% of parent: %{percentParent:.2%}"

    hover.lines <- c(
      "%{customdata.meta}",
      stat.line,
      pct.parent.line,
      "% of total: %{percentRoot:.2%}"
    )

    hover <- paste(hover.lines[nzchar(hover.lines)], collapse = "<br>")
    hover <- paste0(hover, "<extra></extra>")

    # map top-level category -> color, then inherit down the tree
    # use fill.vec's own names so per-facet overrides (fill_vec_byfac)
    # keep their own ordering rather than borrowing the global top.levels
    col.map   <- setNames(as.character(fill.vec), names(fill.vec))
    node.cols <- unname(col.map[as.character(nd$root)])
    node.cols[is.na(node.cols)] <- unname(col.map[1L])  # fallback if any NA

    n.nodes <- length(nd$ids)
    marker <- list(
      colors = node.cols,
      line   = list(
        color = I(rep(border.hex, n.nodes)),
        width = I(rep(1,          n.nodes))
      )
    )

    plt.out <- plotly::add_trace(
      p,
      type             = type,
      ids              = nd$ids,
      labels           = nd$labels,
      parents          = nd$parents,
      values           = nd$values,
      customdata       = nd$customdata,
      domain           = list(x = domain_xy$x, y = domain_xy$y),
      branchvalues     = "remainder",
      textinfo         = txtinfo,
      texttemplate     = texttemplate.str,
      textfont         = list(size = label.fsize, color = labels_color),
      hovertemplate    = hover,
      marker           = marker
    )
    plt.out
  }  # end make.trace()


# begin analysis ----------------------------------------------------------

  labels          <- match.arg(labels)
  labels_position <- match.arg(labels_position)

  # final flag that controls text on/off for nodes
  # "off" still shows label only — show_text stays TRUE for all label values
  show_text <- isTRUE(show_labels)

  # simple font scaling (same base as other lessR plotlys)
  base.font   <- 18
  label.fsize <- base.font * labels_size *
                   getOption("axis_size", 0.9)


# aggregated node tables (nd / nd_byfac), non.additive, stat.label all
# come pre-built from Chart() via .hier_aggregate(). No data prep here.


# assign colors ----------------------------------------------------------
# Colors arrive pre-resolved from Chart() via .hier_color_resolve(). The
# names of fill_vec are the top-level categories in their final (sorted)
# display order. Per-facet remapping is supplied separately by Chart.

  fill.vec   <- fill_vec
  top.levels <- names(fill.vec)

  # border color, one color for all nodes; consistent with prior behavior
  border.hex <- .to_hex(if (is.null(border)) "white" else border[1L])


# start plot & set base colorway (still used by Plotly defaults) ----------

  p <- plotly::plot_ly()
  p <- plotly::layout(p, colorway = fill.vec)

title_x <- if (is.null(title_x)) {
  if (identical(type, "icicle")) {
    if (!is.null(nd_byfac))  0.50
    else if (n_levels > 1L)  0.64
    else                     0.71
  }
  else 0.50
} else {
  as.numeric(title_x)
}


# -------------------------------------------------------------------------
# non-faceted -------------------------------------------------------------

  if (is.null(nd_byfac)) {
    dom.y.top <- if (identical(type, "sunburst")) 0.95 else 1
    p <- make.trace(p, nd, list(x = c(0, 1), y = c(0, dom.y.top)))

    ttl.text <- main

    # For treemaps, plotly's built-in title expands the top margin based on
    # label font size, squeezing the chart unpredictably. Instead, use an
    # annotation positioned in paper coordinates — full control, no side effects.
    if (type %in% c("treemap", "icicle") && !is.null(ttl.text)) {
      # two-line titles (non.additive with y) need more top space
      has.sub <- grepl("<br>", ttl.text, fixed = TRUE)
      t.px    <- if (has.sub) round(title.size * 2.4)
                 else          round(title.size * 2.1)

      # single annotation with yanchor="bottom" keeps both lines above y=1,
      # entirely in the top margin — never overlaps the treemap "All" rectangle.
      if (has.sub) {
        parts    <- strsplit(ttl.text, "<br>", fixed = TRUE)[[1]]
        ttl.text <- paste0(
          "<span style=\"font-size:16px\">", parts[1], "</span>",
          "<br>",
          "<span style=\"font-size:13px\">", parts[2], "</span>"
        )
      }
      title.anns <- list(list(
        text      = ttl.text,
        x        =title_x, xref="paper", xanchor="center",
        y        =1,       yref="paper", yanchor="bottom",
        showarrow=FALSE,
        font     =list(size=title.size)
      ))

      p <- plotly::layout(p,
        margin     =list(t=t.px, b=8, l=20, r=20),
        annotations = title.anns
      )
    } else {
      p <- plotly::layout(
        p,
        margin = list(
          t = round(title.size * 2.0),
          b = 8,
          l = 20,
          r = if (identical(type, "icicle")) 30 else 20
        ),
        title = list(
          text = ttl.text,
          font = list(size = title.size),
          x = title_x,
          xanchor = "center",
          y = if (!has_y) 0.97 else 1.00,
          yanchor = "top"
        )
      )
    }

    # Ensure unique htmlwidget id (REQUIRED for knitting)
    if (is.null(p$elementId)) {
      p$elementId <- paste0(
        "lessr-hier-",
        format(Sys.time(), "%Y%m%d%H%M%OS3"),
        "-",
        sample.int(1e5, 1)
      )
    }

    return(p)
  }



# -------------------------------------------------------------------------
# faceted -----------------------------------------------------------------

  # facet level order is set by Chart()/.hier_aggregate() via nd_byfac names
  fac.levels <- names(nd_byfac)
  n.fac      <- length(fac.levels)

  # gaps are literal; facet_size scales each panel within its grid cell
  # (the shared rule in .plotly_make_domains_grid, matching .radar.plotly)
  gx <- max(0, as.numeric(facet_opts$gap_x))
  gy <- max(0, as.numeric(facet_opts$gap_y))

  # per-type domain and annotation tweaks
  if (type == "treemap") {
    dom_adj <- function(d) {
      span <- d$y[2] - d$y[1]
      new.y0 <- max(0, d$y[1] - 0.06)
      new.y1 <- new.y0 + span
      if (new.y1 > 1) { new.y1 <- 1; new.y0 <- new.y1 - span }
      list(x = d$x, y = c(new.y0, new.y1), row = d$row, col = d$col)
    }
    ann_adj <- function(i, a, d) {
      a$y <- min(d$y[2] - 0.025, 0.99); a$x <- mean(d$x); a
    }
    rev_dom <- TRUE
  } else if (type == "icicle") {
    dom_adj <- function(d) {
      span <- d$y[2] - d$y[1] * 1.12   # lengthen span a bit
      new.y1 <- max(0, d$y[2] - 0.10)
      new.y0 <- max(0, new.y1 - span)
      if (new.y1 > 1) { new.y1 <- 1; new.y0 <- new.y1 - span }
      list(x = d$x, y = c(new.y0, new.y1), row = d$row, col = d$col)
    }
    ann_adj <- function(i, a, d) {
      a$y <- min(d$y[2] + 0.02, 0.99)
      a$x <- a$x + 0.10; a$xanchor <- "right"; a$yref <- "paper"; a
    }
    rev_dom <- FALSE
  } else {  # sunburst
    dom_adj <- function(d) {
      span <- d$y[2] - d$y[1]
      new.y1 <- min(1, d$y[2] - 0.06)
      new.y0 <- max(0, new.y1 - span)
      list(x = d$x, y = c(new.y0, new.y1), row = d$row, col = d$col)
    }
    ann_adj <- function(i, a, d) {
      a$y <- min(d$y[2] - 0.025, 0.99); a$x <- mean(d$x); a
    }
    rev_dom <- FALSE
  }

  lay <- .plotly_facet_layout(
    n_fac           = n.fac,
    facet_levels    = fac.levels,
    facet_name      = facet_name,
    n_col_max       = facet_opts$n_col_max,
    gap_x           = gx, gap_y = gy,
    facet_size      = facet_opts$facet_size,
    domain_adjust   = dom_adj,
    reverse_domains = rev_dom,
    y_base          = facet_opts$title_y_base,
    y_row_shift     = facet_opts$title_row_shift,
    yanchor         = "bottom",
    ann_adjust      = ann_adj
  )
  domains <- lay$domains
  ann     <- lay$annotations


# add traces for each facet -----------------------------------------------

  for (i in seq_along(fac.levels)) {
    fac.lab <- fac.levels[i]
    nd      <- nd_byfac[[fac.lab]]

    # per-facet luminance remap is supplied by Chart() via fill_vec_byfac
    if (!is.null(fill_vec_byfac) && !is.null(fill_vec_byfac[[fac.lab]]))
      fill.vec <- fill_vec_byfac[[fac.lab]]

    # domain order is already reversed for treemap by .plotly_facet_layout(),
    # so straight indexing here works for all three types
    if (nrow(nd) > 0L)
      p <- make.trace(p, nd, domains[[i]])
  }

  ttl.text <- main

  # if two-line title, render subtitle smaller than main line
  if (!is.null(ttl.text) && grepl("<br>", ttl.text, fixed = TRUE)) {
    parts    <- strsplit(ttl.text, "<br>", fixed = TRUE)[[1]]
    ttl.text <- paste0(
      "<span style=\"font-size:", title.size, "px\">",
      parts[1], "</span>",
      "<br>",
      "<span style=\"font-size:", sub.size, "px\">",
      parts[2], "</span>"
    )
  }


  # Plot --------------------------------------------------------------------

  t.margin <- round(title.size * 2.8)

  p <- plotly::layout(
    p,
    annotations = ann,
    margin = list(
      t = if (identical(type, "treemap")) t.margin - 5 else t.margin,
      b = 8,
      l = if (identical(type, "icicle")) 0.00 else 10,
      r = if (identical(type, "icicle")) 30 else 20
    ),
    title = list(
      text = ttl.text,
      font = list(size = title.size),
      x = if (identical(type, "icicle")) 0.55 else title_x,
      xanchor = "center"
    )
  )

  # plotly auto-expands the top margin based on trace label font sizes —
  # lock it back to the intended value after layout is built
  if (!is.null(p$x$layout$margin))
    p$x$layout$margin$t <- t.margin

  p$elementId <- paste0(
    "lessr-hier-",
    format(Sys.time(), "%Y%m%d%H%M%OS3"),
    "-",
    sample.int(1e5, 1)
  )

  p   # last expression returned
}
