UNPKG

apexcharts

Version:

A JavaScript Chart Library

1,668 lines 54.6 kB
// @ts-check
import TreemapSquared from '../libs/Treemap-squared'
import Graphics from '../modules/Graphics'
import Animations from '../modules/Animations'
import Fill from '../modules/Fill'
import Helpers from './common/treemap/Helpers'
import Filters from '../modules/Filters'

import Utils from '../utils/Utils'
import { Environment } from '../utils/Environment.js'
import { getTreemapRoots } from './common/treemap/Nested'
import { morphKey } from './common/Hierarchy'
import { buildContinuousScale, colorValueOf, readableOn } from './common/treemap/ColorScale'
import {
  BREADCRUMB_HEIGHT,
  avoidChromeOverlap,
  breadcrumbConfig,
  clearBreadcrumb,
  renderBreadcrumb,
} from './common/Breadcrumb'

/**
 * @param {number[]} r
 * @returns {number}
 */
const areaOf = (r) => (r[2] - r[0]) * (r[3] - r[1])

/**
 * ApexCharts TreemapChart Class.
 * @module TreemapChart
 **/

export default class TreemapChart {
  /**
   * @param {import('../types/internal').ChartStateW} w
   * @param {import('../types/internal').ChartContext} ctx
   */
  constructor(w, ctx) {
    this.ctx = ctx
    this.w = w

    this.strokeWidth = this.w.config.stroke.width
    this.helpers = new Helpers(w, ctx)
    this.dynamicAnim = this.w.config.chart.animations.dynamicAnimation

    /** @type {any} */
    this.labels = []

    // Nested state, all resolved per draw.
    /** @type {any[]} */ this.roots = []
    /** @type {any[]} */ this.drawn = []
    /** @type {boolean} */ this.nested = false
    /** @type {boolean} */ this.showParents = false
    /** @type {any} */ this.scale = null
    /** @type {any[]|null} */ this._levelCache = null
    /** @type {number|null} */ this._total = null
    /** @type {number|null} */ this._avgLabelSize = null
    /** @type {any} */ this._tooltipEl = null
    /** Whether the parent tooltip currently owns the shared tooltip element. */
    this._tipOwned = false
    /** How many leaves have taken a captured shape by draw order this render. */
    /** @type {number} */ this._morphLeafIndex = 0
  }

  /**
   * @param {any[]} series
   */
  draw(series) {
    const w = this.w
    const graphics = new Graphics(this.w, this.ctx)
    const fill = new Fill(this.w)

    const ret = graphics.group({
      class: 'apexcharts-treemap',
    })

    if (w.globals.noData) return ret

    /** @type {any[]} */
    const ser = []
    /**
     * @param {number[]} s
     */
    series.forEach((s) => {
      /**
       * @param {number} v
       */
      const d = s.map((/** @type {any} */ v) => {
        return Math.abs(v)
      })
      ser.push(d)
    })

    this.negRange = this.helpers.checkColorRange()

    w.config.series.forEach((/** @type {any} */ s, /** @type {any} */ i) => {
      /**
       * @param {number} l
       */
      s.data.forEach((/** @type {any} */ l) => {
        if (!Array.isArray(this.labels[i])) this.labels[i] = []
        this.labels[i].push(l.x)
      })
    })

    // One layout path for both shapes. A flat series array IS a two-level tree
    // (series, then rows), so it is resolved into one here and laid out by the
    // same recursion the nested case uses; with no padding and no headers that
    // reduces to exactly the two-level squarify it always was.
    const tree = getTreemapRoots(w)
    this.nested = tree.nested
    this.roots = tree.roots
    this.scale = buildContinuousScale(w)

    // A single series is a wrapper, not a level the reader can see, so it is
    // unwrapped: level 0 becomes the outermost group the author actually
    // authored. With several series the series IS the outermost group.
    const drawn =
      tree.roots.length === 1 ? tree.roots[0].children || [] : tree.roots
    this.drawn = drawn

    // Parent containers are opt-out, but only appear at all when the data was
    // authored with `children`. A flat treemap must keep drawing exactly what
    // it always drew, with `seriesTitle` as its only group chrome.
    const parentsCfg = w.config.plotOptions.treemap.parents
    this.showParents =
      parentsCfg.show === true ||
      (parentsCfg.show !== false && this.nested)

    // Click-to-zoom: everything outside the focused branch is left out of the
    // layout entirely, so the focused group gets the whole canvas.
    const focus = this._resolveFocus(drawn)
    const layoutRoots = focus ? [focus] : drawn

    TreemapSquared.generateNested(
      layoutRoots,
      w.layout.gridWidth,
      w.layout.gridHeight,
      {
        padding: (node, depth, rw, rh) =>
          this.showParents ? this._levelPadding(depth, rw, rh) : 0,
        header: (node, depth, rw, rh) =>
          this.showParents ? this._levelHeader(node, depth, rw, rh) : 0,
      },
    )

    // Cross-type morph (sunburst -> treemap) via the optional `morph` feature.
    // Tiles consume the captured marks in draw order, the same order the
    // outgoing renderer laid its own out in, so arc k becomes tile k.
    const morphSrc = this.ctx?.morphTypeChange
    const morphActive =
      !!morphSrc &&
      typeof morphSrc.isActive === 'function' &&
      morphSrc.isActive() &&
      typeof morphSrc.getInitialPathAt === 'function'
    this._morphLeafIndex = 0

    // Leaves grouped by series, in the depth-first order the parse flattened
    // them, so `j` still indexes w.seriesData.series[i].
    const leavesBySeries = this._leavesBySeries(layoutRoots, w.config.series.length)
    const parentsBySeries = this.showParents
      ? this._parentsBySeries(layoutRoots, w.config.series.length)
      : []

    leavesBySeries.forEach((node, i) => {
      const elSeries = graphics.group({
        class: `apexcharts-series apexcharts-treemap-series`,
        seriesName: Utils.escapeString(w.seriesData.seriesNames[i]),
        rel: i + 1,
        'data:realIndex': i,
      })

      // Set up event delegation once per series group instead of per-cell listeners
      graphics.setupEventDelegation(elSeries, '.apexcharts-treemap-rect')

      if (w.config.chart.dropShadow.enabled) {
        const shadow = w.config.chart.dropShadow
        const filters = new Filters(this.w)
        filters.dropShadow(ret, shadow, i)
      }

      const elDataLabelWrap = graphics.group({
        class: 'apexcharts-data-labels',
      })

      const bounds = {
        xMin: Infinity,
        yMin: Infinity,
        xMax: -Infinity,
        yMax: -Infinity,
      }

      // Parents first, so a container and its header sit under the tiles they
      // contain (a treemap has no z-index; paint order is the only ordering).
      if (this.showParents) {
        ;(parentsBySeries[i] || []).forEach((/** @type {any} */ p) => {
          this._drawParent(elSeries, p, i)
        })
      }

      // Cascade: assign each tile a rank ordered by area (descending), so the
      // largest tiles get rank 0 (no delay) and smaller tiles cascade in.
      // Gated by `animateGradually`.
      const animCfg = w.config.chart.animations
      const gradCfg = animCfg.animateGradually
      const cascadeEnabled = gradCfg && gradCfg.enabled !== false
      /** @type {number[]} */
      const cascadeDelays = new Array(node.length).fill(0)
      if (cascadeEnabled) {
        const tileCount = node.length || 1
        const baseDelay = Math.min(
          gradCfg.delay || 0,
          (animCfg.speed * 0.5) / tileCount,
        )
        const ranked = node
          .map(
            /** @param {any} leaf @param {number} k */
            (leaf, k) => ({ j: k, area: leaf.rect ? areaOf(leaf.rect) : 0 }),
          )
          .sort(
            /** @param {{j: number, area: number}} a @param {{j: number, area: number}} b */
            (a, b) => b.area - a.area,
          )
        ranked.forEach(
          /** @param {{j: number, area: number}} item @param {number} rank */
          (item, rank) => {
            cascadeDelays[item.j] = rank * baseDelay
          },
        )
      }

      /**
       * @param {any} leaf
       * @param {number} k
       */
      node.forEach((/** @type {any} */ leaf, /** @type {any} */ k) => {
        const r = leaf.rect
        // A branch whose values are all zero gets no rect. Nothing to draw, but
        // the row still exists in the series matrix, so only the drawing is
        // skipped, never the indexing.
        if (!r) return
        // The data index is the leaf's own position in the flattened series,
        // NOT its position in this array: zooming into a branch draws a subset,
        // and everything keyed by (i, j) must still address the right row.
        const j = leaf._di

        const x1 = r[0]
        const y1 = r[1]
        const x2 = r[2]
        const y2 = r[3]

        bounds.xMin = Math.min(bounds.xMin, x1)
        bounds.yMin = Math.min(bounds.yMin, y1)
        bounds.xMax = Math.max(bounds.xMax, x2)
        bounds.yMax = Math.max(bounds.yMax, y2)

        const colorProps = this._leafColor(i, j)
        const color = colorProps.color

        const pathFill = fill.fillPath({
          color,
          seriesNumber: i,
          dataPointIndex: j,
        })

        // Cross-type morph (sunburst -> treemap): a tile unrolls from the arc
        // that stood for the same row. An arc cannot be expressed as a <rect>,
        // so a morphing tile is drawn as a <path> instead and tweened through
        // the shared polygon interpolator. It keeps the same class, so event
        // delegation, tooltips and styling are unaffected.
        const morphFrom = morphActive ? this._morphSourceForLeaf(leaf) : null

        const elRect = morphFrom
          ? graphics.drawPath({
              d: this._tilePath(x1, y1, x2, y2),
              fill: '#fff',
              stroke: w.config.plotOptions.treemap.useFillColorAsStroke
                ? color
                : w.globals.stroke.colors[i],
              strokeWidth: this.strokeWidth,
              fillOpacity: 1,
            })
          : graphics.drawRect(
              x1,
              y1,
              x2 - x1,
              y2 - y1,
              w.config.plotOptions.treemap.borderRadius,
              '#fff',
              1,
              this.strokeWidth,
              w.config.plotOptions.treemap.useFillColorAsStroke
                ? color
                : w.globals.stroke.colors[i],
            )

        elRect.attr({
          cx: x1,
          cy: y1,
          index: i,
          i,
          j,
          width: x2 - x1,
          height: y2 - y1,
          fill: pathFill,
        })

        elRect.node.classList.add('apexcharts-treemap-rect')
        // See Sunburst: the branch identity a cross-type morph pairs on.
        elRect.node.setAttribute('data:key', morphKey(leaf._key))

        let fromRect = {
          x: x1 + (x2 - x1) / 2,
          y: y1 + (y2 - y1) / 2,
          width: 0,
          height: 0,
        }
        const toRect = {
          x: x1,
          y: y1,
          width: x2 - x1,
          height: y2 - y1,
        }

        if (morphFrom) {
          // The tile is a path here, so the usual rect-attribute grow does not
          // apply: tween the path data from the captured arc instead.
          this._morphTile(
            elRect,
            morphFrom,
            this._tilePath(x1, y1, x2, y2),
            this.ctx.morphTypeChange.getSpeed(),
            i,
            j,
          )
        } else if (
          w.config.chart.animations.enabled &&
          !w.globals.dataChanged
        ) {
          let speed = 1
          if (!w.globals.resized) {
            speed = w.config.chart.animations.speed
          }
          this.animateTreemap(
            elRect,
            fromRect,
            toRect,
            speed,
            // Ranked by draw order, not by data index — the cascade is about
            // what is on screen.
            cascadeDelays[k] || 0,
          )
        }
        if (w.globals.dataChanged) {
          let speed = 1
          if (this.dynamicAnim.enabled && w.globals.shouldAnimate) {
            speed = this.dynamicAnim.speed

            if (
              w.globals.previousPaths[i] &&
              /** @type {Record<string,any>} */ (w.globals.previousPaths[i])[
                j
              ] &&
              /** @type {Record<string,any>} */ (w.globals.previousPaths[i])[j]
                .rect
            ) {
              fromRect = /** @type {Record<string,any>} */ (
                w.globals.previousPaths[i]
              )[j].rect
            }

            this.animateTreemap(elRect, fromRect, toRect, speed)
          }
        }

        let fontSize = this.getFontSize(r)
        if (w.config.plotOptions.treemap.dataLabels.format === 'truncate') {
          fontSize = parseInt(String(w.config.dataLabels.style.fontSize), 10)
        }

        // Everything below measures text, and measuring means building a real
        // <text> node and reading its bbox, which forces layout. On a treemap
        // that is the dominant cost by a wide margin, and most of it is spent
        // on tiles that could never show a label anyway. Decide that from the
        // geometry first, which is free.
        let dataLabels = null
        if (
          w.config.dataLabels.enabled &&
          this._labelCanShow(fontSize, x2 - x1, y2 - y1)
        ) {
          let formattedText = w.config.dataLabels.formatter(this.labels[i][j], {
            value: w.seriesData.series[i][j],
            seriesIndex: i,
            dataPointIndex: j,
            w,
          })
          if (w.config.plotOptions.treemap.dataLabels.format === 'truncate') {
            formattedText = this.truncateLabels(
              String(formattedText),
              fontSize,
              x1,
              y1,
              x2,
              y2,
            )
          }
          if (w.seriesData.series[i][j]) {
            dataLabels = this.helpers.calculateDataLabels({
              text: formattedText,
              x: (x1 + x2) / 2,
              y: (y1 + y2) / 2 + this.strokeWidth / 2 + fontSize / 3,
              i,
              j,
              colorProps,
              fontSize,
              series,
            })
          }
          if (w.config.dataLabels.enabled && dataLabels) {
            this.rotateToFitLabel(
              dataLabels,
              fontSize,
              formattedText,
              x1,
              y1,
              x2,
              y2,
            )
          }
        }
        elSeries.add(elRect)
        if (dataLabels !== null) {
          elSeries.add(dataLabels)
        }
      })

      // `seriesTitle` is the level-0 group label a flat treemap has always
      // drawn. Once parent containers are on, level 0 has a real header strip
      // and the free-floating plate would just sit on top of it.
      const seriesTitle = w.config.plotOptions.treemap.seriesTitle
      if (
        !this.showParents &&
        w.config.series.length > 1 &&
        seriesTitle &&
        seriesTitle.show
      ) {
        const sName =
          /** @type {Record<string,any>} */ (w.config.series[i]).name || ''

        if (sName && bounds.xMin < Infinity && bounds.yMin < Infinity) {
          const {
            offsetX,
            offsetY,
            borderColor,
            borderWidth,
            borderRadius,
            style,
          } = seriesTitle

          const textColor = style.color || w.config.chart.foreColor
          const padding = {
            left: style.padding.left,
            right: style.padding.right,
            top: style.padding.top,
            bottom: style.padding.bottom,
          }

          const textSize = graphics.getTextRects(
            sName,
            style.fontSize,
            style.fontFamily,
          )
          const labelRectWidth = textSize.width + padding.left + padding.right
          const labelRectHeight = textSize.height + padding.top + padding.bottom

          // Position
          const labelX = bounds.xMin + (offsetX || 0)
          const labelY = bounds.yMin + (offsetY || 0)

          // Draw background rect
          const elLabelRect = graphics.drawRect(
            labelX,
            labelY,
            labelRectWidth,
            labelRectHeight,
            borderRadius,
            style.background,
            1,
            borderWidth,
            borderColor,
          )

          const elLabelText = graphics.drawText({
            x: labelX + padding.left,
            y: labelY + padding.top + (textSize?.height ?? 0) * 0.75,
            text: sName,
            fontSize: style.fontSize,
            fontFamily: style.fontFamily,
            fontWeight: style.fontWeight,
            foreColor: textColor,
            cssClass: style.cssClass || '',
          })

          elSeries.add(elLabelRect)
          elSeries.add(elLabelText)
        }
      }

      elSeries.add(elDataLabelWrap)
      ret.add(elSeries)
    })

    this._renderBreadcrumb()

    return ret
  }

  // ----------------------------------------------------------------- levels

  /**
   * Per-level config: `plotOptions.treemap.parents` is the base, and
   * `plotOptions.treemap.levels[depth]` overrides it. Depth 0 is the outermost
   * group actually drawn (the series, unless a single series was unwrapped).
   * @param {number} depth
   * @returns {any}
   */
  _levelCfg(depth) {
    if (!this._levelCache) this._levelCache = []
    if (this._levelCache[depth]) return this._levelCache[depth]

    const tm = this.w.config.plotOptions.treemap
    const base = tm.parents || {}
    const lvl = (tm.levels || [])[depth] || {}
    const merged = {
      ...base,
      ...lvl,
      header: {
        ...(base.header || {}),
        ...(lvl.header || {}),
        style: {
          ...((base.header || {}).style || {}),
          ...((lvl.header || {}).style || {}),
        },
      },
      hover: { ...(base.hover || {}), ...(lvl.hover || {}) },
    }
    this._levelCache[depth] = merged
    return merged
  }

  /**
   * The inset between a parent's edge and its children, at this depth.
   * @param {number} depth
   * @param {number} _rw
   * @param {number} _rh
   * @returns {number}
   */
  _levelPadding(depth, _rw, _rh) {
    const cfg = this._levelCfg(depth)
    return Number(cfg.padding) || 0
  }

  /**
   * The header strip height at this depth, or 0 when the tile is too small to
   * carry one. The geometry library clamps against its own box; this is the
   * legibility rule, which needs the font size and so lives here.
   * @param {any} node
   * @param {number} depth
   * @param {number} rw
   * @param {number} rh
   * @returns {number}
   */
  _levelHeader(node, depth, rw, rh) {
    const cfg = this._levelCfg(depth)
    const header = cfg.header || {}
    if (header.show === false) return 0
    const h = Number(header.height)
    if (!Number.isFinite(h) || h <= 0) return 0
    // A strip narrower than this can never show a readable name, and reserving
    // it would only shrink the children for nothing.
    const minWidth = Number(header.minWidth) || 40
    if (rw < minWidth) return 0
    if (rh < h * 2) return 0
    return h
  }

  // ------------------------------------------------------------------ nodes

  /**
   * Leaves of the drawn tree, bucketed by the series they came from and kept in
   * depth-first order.
   * @param {any[]} roots
   * @param {number} seriesCount
   * @returns {any[][]}
   */
  _leavesBySeries(roots, seriesCount) {
    /** @type {any[][]} */
    const out = new Array(Math.max(1, seriesCount))
    for (let i = 0; i < out.length; i++) out[i] = []
    /** @param {any} node */
    const walk = (node) => {
      if (node.children && node.children.length) {
        node.children.forEach(walk)
      } else {
        const si = node._si || 0
        if (out[si]) out[si].push(node)
      }
    }
    roots.forEach(walk)
    return out
  }

  /**
   * Non-leaf nodes of the drawn tree, bucketed by series, shallowest first so a
   * container is painted before anything nested inside it.
   * @param {any[]} roots
   * @param {number} seriesCount
   * @returns {any[][]}
   */
  _parentsBySeries(roots, seriesCount) {
    /** @type {any[][]} */
    const out = new Array(Math.max(1, seriesCount))
    for (let i = 0; i < out.length; i++) out[i] = []
    /** @param {any} node */
    const walk = (node) => {
      if (!node.children || !node.children.length) return
      const si = node._si || 0
      if (out[si]) out[si].push(node)
      node.children.forEach(walk)
    }
    roots.forEach(walk)
    // Painter's order across the whole set, not just within one branch.
    out.forEach((list) =>
      list.sort(
        (/** @type {any} */ a, /** @type {any} */ b) =>
          (a.depth || 0) - (b.depth || 0),
      ),
    )
    return out
  }

  // ---------------------------------------------------------------- colours

  /**
   * A leaf's fill.
   *
   * Continuous colour (a datum carrying a second metric) takes over when it is
   * configured, because shading a tile by its own area value is precisely what
   * that mode replaces. Everything else - `colorScale.ranges`, `enableShades`,
   * `distributed`, negative handling - is the path it always was.
   *
   * The shape of the return value matches `Helpers.getShadeColor` so the
   * caller cannot tell the two apart.
   *
   * @param {number} i
   * @param {number} j
   * @returns {any}
   */
  _leafColor(i, j) {
    const w = this.w
    if (this.scale) {
      const cv = colorValueOf(w, i, j)
      if (cv != null) {
        const color = this.scale.at(cv)
        return {
          color,
          // The discrete path has never supplied a label colour, so only the
          // continuous path sets one: a diverging ramp runs right through the
          // middle of the luminance range and a fixed light-or-dark label
          // would be unreadable at one end or the other.
          foreColor: readableOn(color),
          colorProps: { color, foreColor: readableOn(color), percent: 0 },
        }
      }
    }
    return this.helpers.getShadeColor(
      w.config.chart.type,
      i,
      j,
      this.negRange,
    )
  }

  // ---------------------------------------------------------------- parents

  /**
   * Resolved container/header colours for a level. Parent marks are chrome, not
   * data, so they default to a neutral tint of the background rather than to a
   * series colour: with continuous colour on the leaves, a coloured container
   * would read as another data value.
   * @param {number} depth
   * @returns {any}
   */
  _parentChrome(depth) {
    const w = this.w
    const cfg = this._levelCfg(depth)
    const dark = w.config.theme.mode === 'dark'
    const header = cfg.header || {}
    const hstyle = header.style || {}

    // Deeper levels sit on top of shallower ones, so each is a little stronger
    // than its parent or the nesting would be invisible.
    const step = Math.min(depth, 3)
    const base = dark ? 255 : 0
    const rgb = `${base},${base},${base}`

    return {
      fill: cfg.fill || `rgba(${rgb},${(dark ? 0.04 : 0.03) + step * 0.02})`,
      fillOpacity: cfg.fillOpacity == null ? 1 : cfg.fillOpacity,
      borderColor: cfg.borderColor || `rgba(${rgb},${dark ? 0.18 : 0.14})`,
      borderWidth: cfg.borderWidth == null ? 1 : cfg.borderWidth,
      borderRadius:
        cfg.borderRadius == null
          ? w.config.plotOptions.treemap.borderRadius
          : cfg.borderRadius,
      headerBg: hstyle.background || `rgba(${rgb},${dark ? 0.1 : 0.07})`,
      headerColor:
        hstyle.color || (dark ? '#e8e8e8' : w.config.chart.foreColor),
      headerFontSize: hstyle.fontSize || '12px',
      headerFontFamily: hstyle.fontFamily || w.config.chart.fontFamily,
      headerFontWeight: hstyle.fontWeight == null ? 600 : hstyle.fontWeight,
      hoverColor:
        (cfg.hover && cfg.hover.color) ||
        (dark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.7)'),
      hoverWidth: (cfg.hover && cfg.hover.width) || 2,
      hoverShow: !(cfg.hover && cfg.hover.show === false),
    }
  }

  /**
   * Draw one parent as a real mark: a container rect, and the header strip the
   * layout already reserved room for.
   *
   * The container is `pointer-events: none` over its interior so the tiles
   * inside it keep receiving hover; only the padding gutter and the header
   * strip belong to the parent.
   *
   * @param {any} elSeries
   * @param {any} node
   * @param {number} i seriesIndex
   */
  _drawParent(elSeries, node, i) {
    const r = node.rect
    if (!r) return
    const w = this.w
    const graphics = new Graphics(this.w, this.ctx)
    const depth = node.depth || 0
    const cfg = this._levelCfg(depth)
    const chrome = this._parentChrome(depth)

    const x1 = r[0]
    const y1 = r[1]
    const width = r[2] - r[0]
    const height = r[3] - r[1]
    if (width <= 0 || height <= 0) return

    const elGroup = graphics.group({
      class: 'apexcharts-treemap-parent',
      'data:depth': depth,
      'data:name': Utils.escapeString(node.name),
    })

    // Cross-type morph: with branch keys on both sides, a container unrolls
    // from the ring that stood for the same branch. A <rect> cannot hold an
    // arc, so a morphing container is drawn as a <path> - same class, so
    // styling and hit-testing are unaffected.
    const key = morphKey(node._key)
    const morphFrom = this._morphKeyed()
      ? this.ctx.morphTypeChange.getInitialPathForKey(key)
      : null

    const elRect = morphFrom
      ? graphics.drawPath({
          d: this._tilePath(x1, y1, x1 + width, y1 + height),
          fill: chrome.fill,
          stroke: chrome.borderColor,
          strokeWidth: chrome.borderWidth,
          fillOpacity: chrome.fillOpacity,
        })
      : graphics.drawRect(
          x1,
          y1,
          width,
          height,
          chrome.borderRadius,
          chrome.fill,
          chrome.fillOpacity,
          chrome.borderWidth,
          chrome.borderColor,
        )
    elRect.node.classList.add('apexcharts-treemap-parent-rect')
    elRect.node.setAttribute('data:key', key)
    elRect.node.setAttribute('data:depth', String(depth))

    if (morphFrom) {
      this._morphTile(
        elRect,
        morphFrom,
        this._tilePath(x1, y1, x1 + width, y1 + height),
        this.ctx.morphTypeChange.getSpeed(),
        i,
        depth,
      )
    }
    // The interior belongs to the children painted on top; the gutter around
    // them is the only part of this rect the pointer should ever reach, and
    // that falls out of the children covering the rest.
    elGroup.add(elRect)

    const headerHeight = node.headerHeight || 0
    // The header's rendered text, before clipping. Also the accessible name of
    // the group, so what is announced matches what is drawn.
    let headerText = ''
    if (headerHeight > 0) {
      const header = cfg.header || {}
      const elHeaderRect = graphics.drawRect(
        x1,
        y1,
        width,
        headerHeight,
        0,
        chrome.headerBg,
        1,
        0,
        'transparent',
      )
      elHeaderRect.node.classList.add('apexcharts-treemap-parent-header')
      elGroup.add(elHeaderRect)

      let text = String(node.name ?? '')
      if (typeof header.formatter === 'function') {
        text = String(
          header.formatter(node.name, {
            value: node.value,
            depth,
            seriesIndex: i,
            node,
            w,
          }),
        )
      } else if (header.showValue) {
        text = `${text}  ${this._formatValue(node.value)}`
      }
      headerText = text

      const offsetX = Number(header.offsetX) || 0
      const align = header.align || 'left'
      const pad = 6
      const maxWidth = Math.max(0, width - pad * 2 - Math.abs(offsetX))
      const fontSize = parseFloat(String(chrome.headerFontSize)) || 12
      const clipped = graphics.getTextBasedOnMaxWidth({
        text,
        maxWidth,
        fontSize,
      })

      if (clipped) {
        let tx = x1 + pad + offsetX
        let anchor = 'start'
        if (align === 'center') {
          tx = x1 + width / 2 + offsetX
          anchor = 'middle'
        } else if (align === 'right') {
          tx = x1 + width - pad + offsetX
          anchor = 'end'
        }

        const elText = graphics.drawText({
          x: tx,
          y:
            y1 +
            headerHeight / 2 +
            fontSize / 3 +
            (Number(header.offsetY) || 0),
          text: clipped,
          textAnchor: anchor,
          fontSize: chrome.headerFontSize,
          fontFamily: chrome.headerFontFamily,
          fontWeight: chrome.headerFontWeight,
          foreColor: chrome.headerColor,
          cssClass: `apexcharts-treemap-parent-label ${
            (header.style && header.style.cssClass) || ''
          }`,
        })
        elText.node.setAttribute('pointer-events', 'none')
        elGroup.add(elText)
      }

      this._attachParentEvents(elHeaderRect.node, node, chrome, elRect)
      this._makeParentAccessible(
        elHeaderRect.node,
        node,
        chrome,
        elRect,
        headerText,
      )
    }

    this._attachParentEvents(elRect.node, node, chrome, elRect)

    // Name the container for assistive tech even when it is not actionable, so
    // the structure is readable rather than an unlabelled stack of rects.
    elGroup.node.setAttribute('role', 'group')
    elGroup.node.setAttribute('aria-label', this._parentLabel(node, headerText))

    elSeries.add(elGroup)
  }

  /**
   * The spoken description of a branch: what it is, how big, and how much of
   * the chart it accounts for.
   *
   * Starts from the header's own rendered text when there is one (before
   * clipping), so the accessible name contains the visible label rather than a
   * differently-formatted number - a formatter that renders "$3.25T" must not
   * be announced as "3246".
   *
   * @param {any} node
   * @param {string} [visibleText] the header text, formatter applied, unclipped
   * @returns {string}
   */
  _parentLabel(node, visibleText) {
    const total = this._drawnTotal()
    const pct = total > 0 ? ((node._area / total) * 100).toFixed(1) : '0'
    const n = this._countLeaves(node)
    const lead =
      visibleText && String(visibleText).trim()
        ? String(visibleText).replace(/\s+/g, ' ').trim()
        : `${node.name}, ${this._formatValue(node._area)}`
    return `${lead}, ${n} ${n === 1 ? 'item' : 'items'}, ${pct}% of total`
  }

  /**
   * Make a branch reachable and operable from the keyboard.
   *
   * Only the header strip takes focus, and only while click-to-zoom is live: a
   * treemap can hold hundreds of tiles, and making every mark a tab stop would
   * bury the chart's own controls behind a few hundred presses. Tabbing the
   * groups and pressing Enter is the same path the mouse takes, and the
   * breadcrumb is already real buttons, so the way back is reachable too.
   *
   * @param {any} el
   * @param {any} node
   * @param {any} chrome
   * @param {any} elRect
   * @param {string} [visibleText] the header's rendered text, unclipped
   */
  _makeParentAccessible(el, node, chrome, elRect, visibleText) {
    if (!el || !el.setAttribute) return
    if (!this._zoomEnabled()) return

    el.setAttribute('role', 'button')
    el.setAttribute('tabindex', '0')
    el.setAttribute(
      'aria-label',
      `${this._parentLabel(node, visibleText)}. Zoom in`,
    )
    // The strip is the affordance, and it labels the group it opens.
    el.setAttribute('aria-expanded', 'false')

    if (!Environment.isBrowser() || !el.addEventListener) return

    // A focus ring: reuse the hover outline so pointer and keyboard land on the
    // same visual, and the SVG needs an explicit one either way (a UA outline on
    // an SVG child is unreliable).
    el.addEventListener('focus', () => {
      elRect.node.setAttribute('stroke', chrome.hoverColor)
      elRect.node.setAttribute('stroke-width', String(chrome.hoverWidth + 1))
    })
    el.addEventListener('blur', () => {
      elRect.node.setAttribute('stroke', chrome.borderColor)
      elRect.node.setAttribute('stroke-width', String(chrome.borderWidth))
    })
    el.addEventListener('keydown', (/** @type {KeyboardEvent} */ e) => {
      if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return
      // Space would scroll the page out from under the chart.
      e.preventDefault()
      this._zoomTo(node, true)
    })
  }

  /**
   * Hover outline, aggregate tooltip and click-to-zoom for a parent mark.
   * @param {any} el the element receiving the pointer
   * @param {any} node
   * @param {any} chrome
   * @param {any} elRect the container rect to outline
   */
  _attachParentEvents(el, node, chrome, elRect) {
    if (!Environment.isBrowser() || !el || !el.addEventListener) return
    const w = this.w

    if (chrome.hoverShow) {
      el.addEventListener('mouseenter', () => {
        elRect.node.setAttribute('stroke', chrome.hoverColor)
        elRect.node.setAttribute('stroke-width', String(chrome.hoverWidth))
      })
      el.addEventListener('mouseleave', () => {
        elRect.node.setAttribute('stroke', chrome.borderColor)
        elRect.node.setAttribute('stroke-width', String(chrome.borderWidth))
      })
    }

    if (w.config.tooltip.enabled) {
      el.addEventListener('mouseenter', (/** @type {MouseEvent} */ e) =>
        this._showParentTooltip(e, node),
      )
      el.addEventListener('mousemove', (/** @type {MouseEvent} */ e) =>
        this._positionTooltip(e),
      )
      el.addEventListener('mouseleave', () => this._hideParentTooltip())
    }

    if (this._zoomEnabled()) {
      el.style.cursor = 'pointer'
      el.addEventListener('click', () => this._zoomTo(node))
    }
  }

  // ------------------------------------------------------------------- zoom

  /**
   * Click-to-zoom is live only when nothing else already owns the click.
   *
   * The drilldown feature is the other way into a hierarchy on a treemap, and
   * the two are different models of the same gesture: drilldown replaces the
   * view one level at a time, zoom reframes a tree that stays whole. Running
   * both would fight over the same click and over the one breadcrumb slot in
   * the wrap, so drilldown wins where it is active and this stands down.
   * @returns {boolean}
   */
  _zoomEnabled() {
    const w = this.w
    const z = w.config.plotOptions.treemap.zoom
    if (!z || !z.enabled || !this.showParents) return false

    const dd = w.config.drilldown
    if (dd && dd.enabled && Array.isArray(dd.series) && dd.series.length) {
      if (!this._warnedZoomConflict) {
        this._warnedZoomConflict = true
        console.warn(
          'ApexCharts treemap: `plotOptions.treemap.zoom` and the drilldown ' +
            'feature both navigate the hierarchy, so zoom is ignored here. ' +
            'Drop `drilldown.series` to zoom a nested treemap instead.',
        )
      }
      return false
    }
    return true
  }

  /**
   * The focused branch for this render, looked up by the key stashed on the
   * last click. Keys are rebuilt identically from the same data, so the focus
   * survives a re-render; a key that no longer resolves (the data changed under
   * it) simply falls back to the whole tree.
   * @param {any[]} drawn
   * @returns {any}
   */
  _resolveFocus(drawn) {
    const key = this.w.globals.treemapFocusKey
    if (!key || !this._zoomEnabled()) return null
    /** @type {any} */
    let found = null
    /** @param {any} node */
    const walk = (node) => {
      if (found) return
      if (node._key === key) {
        found = node
        return
      }
      if (node.children) node.children.forEach(walk)
    }
    drawn.forEach(walk)
    // Only a branch can be focused; a leaf has nothing to zoom into.
    return found && found.children && found.children.length ? found : null
  }

  /**
   * @param {any} node
   * @param {boolean} [restoreFocus] move focus into the new view once it is
   *   drawn. A zoom re-renders the chart, which destroys the element the
   *   keyboard user was standing on; without this they would be returned to the
   *   top of the document.
   */
  _zoomTo(node, restoreFocus = false) {
    const w = this.w
    if (!node || !node.children || !node.children.length) return
    const next = w.globals.treemapFocusKey === node._key ? null : node._key
    w.globals.treemapFocusKey = next
    this._hideParentTooltip()
    const done = this.ctx.update()
    if (!restoreFocus || !done || typeof done.then !== 'function') return
    done.then(() => {
      if (!Environment.isBrowser()) return
      // The breadcrumb is where you are and how you get back, so it is the
      // right landing spot after the view changes under you.
      const crumb = /** @type {any} */ (
        w.dom.baseEl.querySelector(
          '.apexcharts-breadcrumb .apexcharts-breadcrumb-item',
        )
      )
      if (crumb && crumb.focus) {
        crumb.focus()
        return
      }
      // Zoomed all the way back out: no breadcrumb, so return to the strip of
      // the group that was just closed.
      const header = /** @type {any} */ (
        w.dom.baseEl.querySelector(
          '.apexcharts-treemap-parent-header[tabindex]',
        )
      )
      if (header && header.focus) header.focus()
    })
  }

  /**
   * Outermost drawn group -> focus chain, for the breadcrumb.
   *
   * Stops at a drawn root rather than walking all the way to `_parent === null`:
   * when a single series was unwrapped, the series node is still every level-0
   * node's parent, and it is not a level the reader ever sees.
   */
  _focusChain() {
    /** @type {any[]} */
    const chain = []
    const drawnRoots = new Set(this.drawn || [])
    let n = this._resolveFocus(this.drawn || [])
    while (n) {
      chain.unshift(n)
      if (drawnRoots.has(n)) break
      n = n._parent
    }
    return chain
  }

  /** The breadcrumb config: a treemap-local override on the shared block. */
  _breadcrumbCfg() {
    const z = this.w.config.plotOptions.treemap.zoom
    return breadcrumbConfig(this.w, z && z.breadcrumb)
  }

  /**
   * Breadcrumb back out of a zoom. Markup, config and accessible semantics are
   * the shared ones, so a zoomed treemap and a drilled-in chart present the
   * same affordance.
   */
  _renderBreadcrumb() {
    if (!Environment.isBrowser()) return
    const w = this.w
    if (!w.dom.elWrap) return
    clearBreadcrumb(w)
    if (!this._zoomEnabled()) return

    const chain = this._focusChain()
    if (!chain.length) return

    const nav = renderBreadcrumb(w, {
      ariaLabel: 'Treemap breadcrumb',
      config: this._breadcrumbCfg(),
      compact: true,
      crumbs: [{ label: 'All', data: null }].concat(
        chain.map((/** @type {any} */ n) => ({ label: n.name, data: n })),
      ),
      onNavigate: (_i, crumb) => {
        w.globals.treemapFocusKey = crumb.data ? crumb.data._key : null
        this._hideParentTooltip()
        this.ctx.update()
      },
    })
    if (!nav) return

    this._placeBreadcrumb(nav)
    avoidChromeOverlap(w, nav)
  }

  /**
   * Sit the breadcrumb in the band the layout reserved for it
   * (Dimensions.gridPadForBreadcrumb), just above the grid.
   *
   * The fallback below should never fire: the reserve is unconditional once
   * zoom is enabled. It stays for the cases the reserve cannot cover - a
   * responsive override that turns zoom on after layout, or a host stylesheet
   * that grows the font - where a readable chip over the tiles beats a
   * breadcrumb clipped by the plot.
   *
   * @param {any} nav
   */
  _placeBreadcrumb(nav) {
    const w = this.w
    const gridTop = w.layout.translateY || 0
    const dimHelpers = this.ctx?.dimensions?.dimHelpers
    const titleArea = dimHelpers
      ? dimHelpers.getTitleSubtitleCoords('title').height +
        dimHelpers.getTitleSubtitleCoords('subtitle').height
      : 0

    const navH = nav.getBoundingClientRect().height || BREADCRUMB_HEIGHT
    if (gridTop - titleArea >= navH + 1) {
      nav.style.top = `${gridTop - navH - 1}px`
      return
    }

    nav.style.top = `${titleArea}px`
    const dark = w.config.theme.mode === 'dark'
    nav.style.background = dark
      ? 'rgba(20,24,30,0.82)'
      : 'rgba(255,255,255,0.86)'
    nav.style.borderRadius = '4px'
  }

  // ---------------------------------------------------------------- tooltip

  /**
   * A parent is not a row in the series matrix, so the shared tooltip - which
   * addresses everything by (seriesIndex, dataPointIndex) - has nothing to look
   * up. It writes into the same tooltip element instead, so the aggregate looks
   * like every other tooltip in the chart.
   * @param {MouseEvent} e
   * @param {any} node
   */
  _showParentTooltip(e, node) {
    const w = this.w
    const t = this._tip()
    if (!t) return

    const total = this._drawnTotal()
    const parentVal = node._parent ? node._parent._area : total
    const pctTotal = total > 0 ? ((node._area / total) * 100).toFixed(1) : '0.0'
    const pctParent =
      parentVal > 0 ? ((node._area / parentVal) * 100).toFixed(1) : pctTotal

    const cfg = this.w.config.plotOptions.treemap.parents
    const custom = cfg && cfg.tooltip && cfg.tooltip.formatter
    const leafCount = this._countLeaves(node)

    let html
    if (typeof custom === 'function') {
      html = custom({
        name: node.name,
        value: node._area,
        depth: node.depth || 0,
        leafCount,
        percentOfParent: Number(pctParent),
        percentOfTotal: Number(pctTotal),
        node,
        w,
      })
    } else {
      const marker = this._parentChrome(node.depth || 0).headerBg
      const groupBg = w.config.tooltip.fillSeriesColor
        ? `background-color:${marker};`
        : ''
      html =
        `<div class="apexcharts-tooltip-series-group apexcharts-active" style="display:flex;${groupBg}">` +
        `<div class="apexcharts-tooltip-text">` +
        `<div class="apexcharts-tooltip-y-group">` +
        `<span class="apexcharts-tooltip-text-y-label">${Utils.escapeString(
          node.name,
        )}: </span>` +
        `<span class="apexcharts-tooltip-text-y-value">${this._formatValue(
          node._area,
        )}</span>` +
        `</div>` +
        `<div class="apexcharts-tooltip-y-group">` +
        `<span class="apexcharts-tooltip-text-y-label">${leafCount} items, </span>` +
        `<span class="apexcharts-tooltip-text-y-value">${pctParent}% of parent, ${pctTotal}% of total</span>` +
        `</div>` +
        `</div></div>`
    }

    t.innerHTML = html
    t.classList.add('apexcharts-active')
    t.style.opacity = '1'
    this._tipOwned = true
    this._positionTooltip(e)
  }

  /** @returns {any} */
  _tip() {
    if (!this._tooltipEl) {
      this._tooltipEl = this.w.dom.baseEl.querySelector('.apexcharts-tooltip')
    }
    return this._tooltipEl
  }

  /**
   * Position beside the cursor, flipping to the opposite side when the box
   * would overflow the chart wrap, and clamping inside it either way.
   * @param {MouseEvent} e
   */
  _positionTooltip(e) {
    const t = this._tip()
    if (!t || !this._tipOwned) return
    const rect = this.w.dom.elWrap.getBoundingClientRect()
    const tw = t.offsetWidth
    const th = t.offsetHeight
    const pad = 12

    let x = e.clientX - rect.left + pad
    if (x + tw > rect.width) x = e.clientX - rect.left - tw - pad
    x = Math.max(0, Math.min(x, rect.width - tw))

    let y = e.clientY - rect.top + pad
    if (y + th > rect.height) y = e.clientY - rect.top - th - pad
    y = Math.max(0, Math.min(y, rect.height - th))

    t.style.left = x + 'px'
    t.style.top = y + 'px'
  }

  _hideParentTooltip() {
    const t = this._tip()
    // Only hide what this renderer put there. Moving from a parent's gutter
    // onto one of its tiles fires the leave here after the shared tooltip has
    // already taken the element over for the tile.
    if (!t || !this._tipOwned) return
    this._tipOwned = false
    t.classList.remove('apexcharts-active')
    t.style.opacity = '0'
  }

  /**
   * The whole tree's area, zoomed in or not: "% of total" has to mean the same
   * thing at every zoom level, otherwise the branch you just zoomed into
   * reports 100%.
   * @returns {number}
   */
  _drawnTotal() {
    if (this._total == null) {
      this._total = (this.drawn || []).reduce(
        (/** @type {number} */ s, /** @type {any} */ r) =>
          s + this._subtreeArea(r),
        0,
      )
    }
    return this._total || 0
  }

  /**
   * A node's area, computed the same way the layout does it. Zooming lays out
   * only the focused branch, so every other branch reaches here without the
   * `_area` the layout would otherwise have left on it.
   * @param {any} node
   * @returns {number}
   */
  _subtreeArea(node) {
    if (node._area != null) return node._area
    const kids = node.children
    if (kids && kids.length) {
      let s = 0
      for (let i = 0; i < kids.length; i++) s += this._subtreeArea(kids[i])
      return s
    }
    const v = Number(node.value)
    return isNaN(v) ? 0 : Math.abs(v)
  }

  /**
   * @param {any} node
   * @returns {number}
   */
  _countLeaves(node) {
    if (!node.children || !node.children.length) return 1
    return node.children.reduce(
      (/** @type {number} */ s, /** @type {any} */ c) =>
        s + this._countLeaves(c),
      0,
    )
  }

  /**
   * Format an aggregate the way the chart formats its own y values, so a
   * parent's total reads like the tiles it contains.
   * @param {number} v
   * @returns {string}
   */
  _formatValue(v) {
    const w = this.w
    const fmt = w.config.tooltip?.y?.formatter || w.config.yaxis?.[0]?.labels?.formatter
    if (typeof fmt === 'function') {
      try {
        return String(fmt(v, { seriesIndex: 0, dataPointIndex: 0, w }))
      } catch (_) {
        // A formatter written for leaf rows may expect fields a parent
        // aggregate has no equivalent of; fall through to the plain number.
      }
    }
    return String(v)
  }

  /**
   * Whether a tile could show a label at all, decided from geometry alone so
   * nothing has to be measured to find out.
   *
   * Two rules, both about legibility rather than about saving work:
   *   - a tile that cannot hold one character in EITHER orientation has no
   *     label to draw, rotated or not
   *   - below `dataLabels.minFontSize` the text is decoration, not information
   *
   * Together these keep a dense treemap from spending its whole render
   * measuring text nobody can read.
   *
   * @param {number} fontSize
   * @param {number} tileWidth
   * @param {number} tileHeight
   * @returns {boolean}
   */
  _labelCanShow(fontSize, tileWidth, tileHeight) {
    if (!Number.isFinite(fontSize) || fontSize <= 0) return false
    const cfg = this.w.config.plotOptions.treemap.dataLabels
    const min = cfg && cfg.minFontSize != null ? Number(cfg.minFontSize) : 0
    if (fontSize < min) return false
    // `rotateToFitLabel` may turn the text sideways, so the long side is what
    // has to hold the glyph height.
    return Math.max(tileWidth, tileHeight) >= fontSize
  }

  /**
   * Mean label length across the whole chart.
   *
   * It scales every tile's font size, and it is the same number for all of
   * them, so it is computed once per draw. It used to be recomputed inside
   * `getFontSize`, which runs per tile: two full walks of every label, per
   * label, which is quadratic and was the entire cost of a large treemap (a
   * 10k-tile chart spent ~9s here).
   *
   * @returns {number}
   */
  _averageLabelSize() {
    if (this._avgLabelSize != null) return this._avgLabelSize

    // total length of labels (i.e [["Italy"],["Spain", "Greece"]] -> 16)
    /**
     * @param {any[]} arr
     */
    function totalLabelLength(arr) {
      let i,
        total = 0
      if (Array.isArray(arr[0])) {
        for (i = 0; i < arr.length; i++) {
          total += totalLabelLength(arr[i])
        }
      } else {
        for (i = 0; i < arr.length; i++) {
          total += arr[i].length
        }
      }
      return total
    }

    // count of labels (i.e [["Italy"],["Spain", "Greece"]] -> 3)
    /**
     * @param {any[]} arr
     */
    function countLabels(arr) {
      let i,
        total = 0
      if (Array.isArray(arr[0])) {
        for (i = 0; i < arr.length; i++) {
          total += countLabels(arr[i])
        }
      } else {
        for (i = 0; i < arr.length; i++) {
          total += 1
        }
      }
      return total
    }

    this._avgLabelSize =
      totalLabelLength(this.labels) / countLabels(this.labels)
    return this._avgLabelSize
  }

  // This calculates a font-size based upon
  // average label length and the size of the box
  /**
   * @param {number[]} coordinates
   */
  getFontSize(coordinates) {
    const w = this.w

    const averagelabelsize = this._averageLabelSize()

    /**
     * @param {number} width
     * @param {number} height
     */
    function fontSize(width, height) {
      const area = width * height
      const arearoot = Math.pow(area, 0.5)
      return Math.min(
        arearoot / averagelabelsize,
        parseInt(w.config.dataLabels.style.fontSize, 10),
      )
    }

    return fontSize(
      coordinates[2] - coordinates[0],
      coordinates[3] - coordinates[1],
    )
  }

  /**
   * @param {any} elText
   * @param {string | number} fontSize
   * @param {string} text
   * @param {number} x1
   * @param {number} y1
   * @param {number} x2
   * @param {number} y2
   */
  rotateToFitLabel(elText, fontSize, text, x1, y1, x2, y2) {
    const graphics = new Graphics(this.w)
    const textRect = graphics.getTextRects(text, String(fontSize))

    // if the label fits better sideways then rotate it
    if (
      textRect.width + this.w.config.stroke.width + 5 > x2 - x1 &&
      textRect.width <= y2 - y1
    ) {
      const labelRotatingCenter = graphics.rotateAroundCenter(elText.node)

      elText.node.setAttribute(
        'transform',
        `rotate(-90 ${labelRotatingCenter.x} ${
          labelRotatingCenter.y
        }) translate(${textRect.height / 3})`,
      )
    }
  }

  // This is an alternative label formatting method that uses a
  // consistent font size, and trims the edge of long labels
  /**
   * @param {string} text
   * @param {number} fontSize
   * @param {number} x1
   * @param {number} y1
   * @param {number} x2
   * @param {number} y2
   */
  truncateLabels(text, fontSize, x1, y1, x2, y2) {
    const graphics = new Graphics(this.w)
    const textRect = graphics.getTextRects(text, String(fontSize))

    // Determine max width based on ideal orientation of text
    const labelMaxWidth =
      textRect.width + this.w.config.stroke.width + 5 > x2 - x1 &&
      y2 - y1 > x2 - x1
        ? y2 - y1
        : x2 - x1
    const truncatedText = graphics.getTextBasedOnMaxWidth({
      text: text,
      maxWidth: labelMaxWidth,
      fontSize: fontSize,
    })

    // Return empty label when text has been trimmed for very small rects
    if (text.length !== truncatedText.length && labelMaxWidth / fontSize < 5) {
      return ''
    } else {
      return truncatedText
    }
  }

  /**
   * True when the active cross-type morph can pair marks by branch identity
   * rather than by draw order.
   * @returns {boolean}
   */
  _morphKeyed() {
    const m = this.ctx?.morphTypeChange
    return !!(
      m &&
      typeof m.hasKeyedMarks === 'function' &&
      m.hasKeyedMarks() &&
      typeof m.getInitialPathForKey === 'function'
    )
  }

  /**
   * The captured shape a LEAF tile unrolls from, or null when the outgoing
   * chart had nothing to give it.
   *
   * Branch identity first, so a tile unrolls from the arc that stood for the
   * same row rather than the k-th one. That key can find nothing even when
   * both sides carry keys, and a FLAT treemap taking from a nested sunburst is
   * exactly that case: its tiles are keyed at depth one ('/0:Tops') while the
   * arcs are keyed at the depth they actually sit ('/0:Apparel/0:Tops'), so no
   * key ever matches. Draw order is the fallback, the same one the mirror
   * direction has always had (Sunburst._morphSourceFor), and without it
   * treemap -> sunburst read as a morph while the return trip grew from
   * nothing.
   *
   * Leaves only. A container taking a leaf's path by position would unroll
   * from a different branch entirely, so `_morphParent` stays key-or-nothing.
   *
   * @param {any} leaf
   * @returns {string | null}
   */
  _morphSourceForLeaf(leaf) {
    const morph = this.ctx?.morphTypeChange
    if (!morph || typeof morph.getInitialPathAt !== 'function') return null
    if (this._morphKeyed()) {
      const keyed = morph.getInitialPathForKey(morphKey(leaf._key))
      if (keyed) return keyed
    }
    return morph.getInitialPathAt(this._morphLeafIndex++)
  }

  /**
   * A tile as closed path data, for the cross-type morph (a <rect> cannot hold
   * an arc, so a morphing tile is drawn as a <path>).
   * @param {number} x1 @param {number} y1 @param {number} x2 @param {number} y2
   * @returns {string}
   */
  _tilePath(x1, y1, x2, y2) {
    return `M ${x1} ${y1} L ${x2} ${y1} L ${x2} ${y2} L ${x1} ${y2} Z`
  }

  /**
   * Tween a tile's path data from a captured shape (a sunburst arc) into its
   * rectangle.
   *
   * Routed through Animations.morphSVG, the same call every other morphing
   * renderer makes: it already selects the polygon-resample algorithm while a
   * cross-type morph is active, which is what tweens between shapes as
   * different as an arc and a rectangle. Reaching for the interpolator directly
   * would also add a name to a module the split bundles share, which breaks
   * them.
   *
   * @param {any} el
   * @param {string} fromD
   * @param {string} toD
   * @param {number} speed
   * @param {number} i
   * @param {number} j
   */
  _morphTile(el, fromD, toD, speed, i, j) {
    const animations = new Animations(this.w, this.ctx)
    animations.morphSVG(el, i, j, 'none', fromD, toD, speed, 0)
  }

  /**
   * @param {any} el
   * @param {Record<string, any>} fromRect
   * @param {Record<string, any>} toRect
   * @param {number} speed
   * @param {number} [delay] - per-tile cascade delay in ms
   */
  animateTreemap(el, fromRect, toRect, speed, delay = 0) {
    const animations = new Animations(this.w)
    animations.animateRect(
      el,
      fromRect,
      toRect,
      speed,
      () => {
        animations.animationCompleted(el)
      },
      delay,
    )
  }
}