UNPKG

apexcharts

Version:

A JavaScript Chart Library

1,383 lines 54.3 kB
// @ts-check
/**
 * Trellis (#22): the orchestrator.
 *
 * A trellis is a coordination layer over REAL ApexCharts panels: the host
 * instance never runs its own render pipeline; this module owns the split
 * (TrellisSplit), the shared scales (TrellisScales), the grid (TrellisLayout),
 * the shared chrome (TrellisChrome) and the cross-panel state the group cannot
 * express (TrellisSync). Every panel is an ordinary chart joined into an
 * implicit `chart.group`, which is where tooltip fan-out, the crosshair sweep
 * and x-window zoom sync come from.
 *
 * Alignment contract (the feature's headline, spike 22a):
 *   - shared scales: identical `{min,max,tickAmount}` into every panel means
 *     identical geometry out, ragged data included, because the split has
 *     already re-emitted every panel against the union x list (D5).
 *   - independent y: mount hidden, measure `max(yLabelsCoords[0].width)`,
 *     push one `yaxis.labels.minWidth` to every panel (converges exactly in
 *     one pass, Q2), then reveal.
 *   - edge-label policy is CSS (a class on non-edge cells hides the label
 *     INK, never the label SPACE), so geometry stays uniform by construction
 *     and a resize that moves the bottom row re-renders nothing.
 *
 * Eager optional module (`ctx.trellis`), self-inert unless `config.trellis.by`
 * is set. The host's render()/destroy()/updateSeries() delegate here.
 *
 * @module modules/trellis/Trellis
 */
import ApexCharts from '../../apexcharts'
import Utils from '../../utils/Utils'
import { Environment } from '../../utils/Environment.js'
import { BrowserAPIs } from '../../ssr/BrowserAPIs.js'
import { addResizeListener, removeResizeListener } from '../../utils/Resize'
import RendererController from '../RendererController'
import {
  computeMarkCount,
  hasCanvasUnsupportedFeature,
} from '../../renderers/Renderer'
import { split as splitSeries, placeholderSeries, keyOf } from './TrellisSplit'
import { buildTypeFrames } from './TrellisFrames'
import { pivotRows } from './pivotRows'
import * as TrellisScales from './TrellisScales'
import * as TrellisLayout from './TrellisLayout'
import TrellisChrome from './TrellisChrome'
import TrellisSync, { yaxisPayload } from './TrellisSync'
import TrellisVirtual from './TrellisVirtual'
import TrellisTooltip from './TrellisTooltip'
import TrellisExports from './TrellisExports'

/** Above this, `virtualize: 'auto'` mounts only the visible panels (P2). */
const EAGER_PANEL_BUDGET = 64

/** Above this, warn even when virtualized: point at trellis.limit. */
const HARD_PANEL_WARN = 256

/** Above this, panel mount animations default OFF (explicit user wins). */
const ANIMATION_PANEL_BUDGET = 16

/** The vertical-axis label pad inside the yLabelsCoords width (22a Q2). */
const Y_LABEL_PAD = 10

/**
 * Vertical air a standalone chart reserves that a trellis panel must NOT
 * (the header and grid gap are the breathing room here; inside the panel it
 * is dead space that visibly pushes rows apart). With the panel's empty
 * title/subtitle at margin 0, Dimensions still leaves a 20px no-title gutter
 * above the plot and a flat 15px of slack below the x-axis band; these
 * paddings (composed with the user's own grid.padding) reclaim most of it.
 * The bottom is axis-aware: TIMESCALE labels draw ~6px deeper into that
 * slack than the category/numeric label path (measured: a standalone
 * datetime chart keeps only ~5px of true clearance), so a datetime panel
 * may only take 2. Values are calibrated to Dimensions' constants; the
 * compact-chrome interaction test pins both the reclaim AND that no x-label
 * ink is cropped by the svg bottom.
 */
const PANEL_PAD_RECLAIM_TOP = 12
const PANEL_PAD_RECLAIM_BOTTOM = 7
const PANEL_PAD_RECLAIM_BOTTOM_DATETIME = 2

/**
 * Panel height (px) below which panels default to `tooltip.compact`. A normal
 * card runs ~45px for one row and ~110px for three, so on a panel this short
 * it covers the line it is captioning. Set `tooltip.compact` yourself (either
 * value) and the trellis leaves the decision alone.
 */
const COMPACT_TOOLTIP_PANEL_H = 120

/**
 * Chart types a trellis refuses (P5, plan §10): the reading does not survive
 * a small frame, so the host warns and renders a single chart instead of an
 * unreadable grid. `unit` is a redirect, not a limitation: its native
 * `plotOptions.unit.grid.split` already draws one mini-waffle per category
 * inside a single instance, which beats N unit panels.
 * @type {Record<string, string>}
 */
const TYPE_VETO = {
  treemap:
    'trellis does not support treemap: area encoding needs room a panel cannot give. Rendering a single chart.',
  sunburst:
    'trellis does not support sunburst: its labels are illegible at panel size. Rendering a single chart.',
  unit: 'the unit type has its own small-multiples mode (plotOptions.unit.grid.split), which beats a trellis of unit charts. Rendering a single chart.',
}

/**
 * Grid-level canvas auto-selection (P2, plan §7.12): a trellis is dense in
 * TOTAL, never per panel, so the per-chart `renderer: 'auto'` heuristic can
 * never trip inside a panel. Decide once over the whole grid's mark count
 * (same `computeMarkCount` heuristic, same `chart.rendererThreshold` knob)
 * and push an explicit uniform `renderer: 'canvas'` into every panel.
 * Declines when the user set `chart.renderer` themselves, when the canvas
 * feature is not registered, or when the config uses a canvas-unsupported
 * feature (each panel would warn-and-fall-back N times otherwise).
 *
 * @param {import('./TrellisSplit').TrellisSplitResult} split
 * @param {any} hostConfig the host's full w.config
 * @param {any} userOpts   the host's RAW user options (explicitness check)
 * @param {boolean} canvasRegistered
 * @returns {'canvas'|null}
 */
export function choosePanelRenderer(
  split,
  hostConfig,
  userOpts,
  canvasRegistered,
) {
  if (userOpts && userOpts.chart && userOpts.chart.renderer) return null
  if (!canvasRegistered) return null
  const shim = {
    config: {
      series: split.panels.reduce(
        (/** @type {any[]} */ acc, p) => acc.concat(p.series),
        [],
      ),
      chart: { type: hostConfig.chart.type },
      markers: hostConfig.markers,
      dataLabels: hostConfig.dataLabels,
      fill: hostConfig.fill,
      plotOptions: hostConfig.plotOptions,
      states: hostConfig.states,
    },
  }
  if (hasCanvasUnsupportedFeature(shim)) return null
  const threshold = hostConfig.chart.rendererThreshold || 8000
  return computeMarkCount(shim) >= threshold ? 'canvas' : null
}

/**
 * Trellis-scoped annotations (P3): filter one annotations config for one
 * panel. An annotation with no `scope` (or `scope: 'trellis'`) draws in
 * EVERY panel, projected through that panel's own scale; a string or string[]
 * scope names the panel key(s) it belongs to. The `scope` key itself is
 * stripped from the copy the panel receives.
 *
 * Pure; exported for tests.
 *
 * @param {Record<string, any>|undefined} annotations a cloned annotations config
 * @param {string} key the panel's facet key
 * @returns {Record<string, any>|undefined}
 */
export function scopeAnnotations(annotations, key) {
  if (!annotations || typeof annotations !== 'object') return annotations
  const out = { ...annotations }
  for (const kind of ['yaxis', 'xaxis', 'points', 'texts', 'images']) {
    const list = out[kind]
    if (!Array.isArray(list)) continue
    out[kind] = list
      .filter((item) => {
        if (!item || item.scope === undefined || item.scope === null) {
          return true
        }
        if (item.scope === 'trellis') return true
        if (Array.isArray(item.scope)) {
          return item.scope.map(String).indexOf(key) !== -1
        }
        return String(item.scope) === key
      })
      .map((item) => {
        if (!item || item.scope === undefined) return item
        const copy = { ...item }
        delete copy.scope
        return copy
      })
  }
  return out
}

export default class Trellis {
  /**
   * @param {import('../../types/internal').ChartStateW} w
   * @param {import('../../types/internal').ChartContext} ctx
   */
  constructor(w, ctx) {
    this.w = w
    this.ctx = ctx
    /**
     * Panel records. `wantMounted`/`viewStash` are virtualization state (P2):
     * the observer's latest verdict and the view captured at unmount.
     * `noMount` (P4) marks empty combinations under 'skip'/'hide'.
     * @type {Array<{ key: string, index: number, el: HTMLElement|null, cellEl: HTMLElement|null, chart: any|null, empty: boolean, noMount?: boolean, wantMounted?: boolean, viewStash?: any }>}
     */
    this.panels = []
    /** @type {HTMLElement[]} 2-D row/column strip elements (P4) */
    this._stripEls = []
    /** @type {import('./TrellisSplit').TrellisSplitResult|null} */
    this.split = null
    /** @type {ReturnType<typeof TrellisScales.resolve>|null} */
    this.scales = null
    /** @type {import('./TrellisLayout').TrellisLayoutResult|null} */
    this.layout = null
    /** @type {Record<string, any>} normalized trellis config */
    this.cfg = {}
    this.sync = new TrellisSync(this)
    this.chrome = new TrellisChrome(this)
    this.virtual = new TrellisVirtual(this)
    this.exports = new TrellisExports(this)
    /** @type {TrellisTooltip|null} the grid-mode tooltip card (P3) */
    this.gridTooltip = null
    /** @type {string|null} the promoted panel's key (P3) */
    this._promotedKey = null
    /** The panel constructor, exposed so TrellisVirtual stays import-light. */
    this._ApexCharts = ApexCharts
    /** @type {boolean} whether this render virtualizes (P2) */
    this._virtualActive = false
    /** @type {'canvas'|null} uniform panel renderer override (P2) */
    this._panelRenderer = null
    /** @type {number} uniform y-label decimals across the grid (P4) */
    this._yLabelDecimals = 0
    /** @type {HTMLElement|null} the outer trellis element (w.dom.elWrap) */
    this.elWrap = null
    /** @type {HTMLElement|null} */
    this.elGrid = null
    this._mounted = false
    this._rendering = false
    /** rAF handle for the coalesced relayout */
    this._raf = 0
    this._lastWidth = 0
    this._resizeHandler = this._onContainerResize.bind(this)
    this.autoScaleYaxis = false
    /** @type {HTMLElement|null} */
    this._elChromeTop = null
    /** Whether the type-veto warning (P5) has been emitted once. */
    this._vetoWarned = false
    /** Whether the unmatched-`by` warning has been emitted once. */
    this._noKeyWarned = false
    /** @type {import('./TrellisFrames').TypeFrames|null} shared type frames (P5) */
    this._frames = null
  }

  /** Whether this chart is a trellis host: `by` (1-D) or `row`/`column`
   *  (2-D, P4) is the switch. A vetoed chart type (P5) returns false, which
   *  makes EVERY host seam (render delegation, update branches, exports,
   *  promotion) fall back to the single-chart pipeline at once. */
  isActive() {
    const t = this.w.config.trellis
    if (!(t && (t.by || t.row || t.column))) return false
    const type = this.w.config.chart?.type
    if (type && TYPE_VETO[type]) {
      if (!this._vetoWarned) {
        this._vetoWarned = true
        console.warn(`ApexCharts: ${TYPE_VETO[type]}`)
      }
      return false
    }

    // A `by` key that no series carries splits into zero panels. The split
    // itself already noticed this, but it runs inside render(), by which point
    // the host has committed to the trellis path and deliberately skipped its
    // resize listeners (the trellis owns relayout). Bailing out there left an
    // empty container; falling back there would leave a chart that never
    // relayouts. So the decision belongs here, alongside TYPE_VETO, which is
    // the fallback route the host already handles correctly.
    //
    // Scoped to the plain `by` case on purpose: `row`/`column` and the
    // `trellis.data` pivot resolve their keys differently, and reproducing
    // that here would be a second copy of the split's rules.
    if (t.by && !t.row && !t.column && !Array.isArray(t.data)) {
      const list = this.w.config.series || []
      const anyKeyed = list.some(
        (/** @type {any} */ s, /** @type {number} */ i) =>
          keyOf(s, i, t.by) !== null,
      )
      if (!anyKeyed) {
        if (!this._noKeyWarned) {
          this._noKeyWarned = true
          const named =
            typeof t.by === 'function'
              ? 'the `trellis.by` function returned no key for any series'
              : `no series carries the \`trellis.by\` key '${t.by}'`
          console.warn(
            `ApexCharts: ${named}; there is nothing to split. Rendering as a single chart.`,
          )
        }
        return false
      }
    }
    return true
  }

  /** The layout-facing config: a 2-D grid has a FIXED column count (one per
   *  column key; responsive recolumning would break the row/column
   *  semantics), so panels shrink instead of wrapping. */
  _layoutCfg() {
    if (this.split && this.split.mode === '2d' && this.split.colKeys) {
      return { ...this.cfg, columns: this.split.colKeys.length }
    }
    return this.cfg
  }

  /**
   * The EFFECTIVE y scale mode: the user's, unless a type frame (P5) forced
   * 'shared' because group modes are meaningless for the chart type.
   * @returns {string}
   */
  _yMode() {
    if (this._frames && this._frames.forceSharedY) return 'shared'
    return this.cfg.scales?.y || 'shared'
  }

  /**
   * The y bounds one panel should carry, per the scales mode: the shared
   * union, its row's union ('independent-row'), its column's union
   * ('independent-column'), or none ('independent'). Types whose y axis does
   * not carry the data values (heatmap rows, the radial family) never get a
   * yaxis push at all (P5): the frame's own channel is the shared one.
   * @param {import('./TrellisSplit').TrellisSlice} slice
   * @returns {{ min: number, max: number, tickAmount: number } | null}
   */
  _yBoundsFor(slice) {
    const scales = this.scales
    if (!scales) return null
    if (this._frames && this._frames.skipYaxisPush) return null
    const yMode = this._yMode()
    if (yMode === 'shared') return scales.y
    if (yMode === 'independent-row' && scales.rowY) {
      return scales.rowY.get(slice.rowKey ?? '') || null
    }
    if (yMode === 'independent-column' && scales.colY) {
      return scales.colY.get(slice.colKey ?? '') || null
    }
    return null
  }

  /**
   * Horizontal bars measure along x, and the library reads that value axis
   * from `xaxis` (Scales.setYScaleForIndex switches on `isBarHorizontal`),
   * so the shared bounds have to be pushed THERE as well: `yaxis` alone
   * seeds the domain but the panel then re-nices it from its own data, which
   * silently discards the trellis's tick target.
   *
   * rangeBar is excluded on purpose: its horizontal value axis carries dates,
   * and a nice-number domain is meaningless on a timeline.
   * @returns {boolean}
   */
  _valueAxisIsX() {
    const cnf = this.w.config
    return (
      ['bar', 'boxPlot', 'violin'].includes(cnf.chart.type) &&
      !!cnf.plotOptions?.bar?.horizontal
    )
  }

  /**
   * The value-axis patch for a horizontal panel. An explicit user
   * `xaxis.tickAmount` wins: the count is theirs to set, and the panels
   * still agree because they all re-nice from the SAME pushed bounds.
   * @param {{ min: number, max: number, tickAmount: number }} bounds
   * @returns {Record<string, any>}
   */
  _valueAxisXPatch(bounds) {
    /** @type {Record<string, any>} */
    const patch = { min: bounds.min, max: bounds.max }
    if (typeof this.ctx.opts?.xaxis?.tickAmount !== 'number') {
      patch.tickAmount = bounds.tickAmount
    }
    return patch
  }

  /** @returns {string} namespaced panel group id */
  _groupId() {
    return `${this.w.globals.chartID}-tg`
  }

  /**
   * Render the whole trellis into the host element. Called by the host's
   * render() INSTEAD of the normal create()/mount() pipeline.
   * @returns {Promise<void>}
   */
  async render() {
    const w = this.w
    if (this._mounted || this._rendering) return
    if (!Environment.isBrowser()) {
      console.warn(
        'ApexCharts: trellis rendering is browser-only in this version; SSR hosts render nothing.',
      )
      return
    }
    this._rendering = true
    try {
      this.cfg = w.config.trellis || {}
      this.autoScaleYaxis = !!w.config.chart?.zoom?.autoScaleYaxis

      // 0. Tidy-row input (P2): `trellis.data` rows pivot to series form and
      //    flow through the same proven split path. Rows win over `series`
      //    when both are given, so the two shapes can never half-merge.
      let inputSeries = w.config.series || []
      if (Array.isArray(this.cfg.data)) {
        const pivoted = pivotRows(this.cfg.data, this.cfg)
        pivoted.warnings.forEach((msg) => console.warn(`ApexCharts: ${msg}`))
        if (pivoted.series.length) {
          if (inputSeries.length) {
            console.warn(
              'ApexCharts: trellis received both `series` and `trellis.data`; using trellis.data.',
            )
          }
          inputSeries = pivoted.series
        }
      }

      // 1. Split (union x alignment included, 22a D5).
      const split = splitSeries(inputSeries, this.cfg)
      this.split = split
      split.warnings.forEach((msg) => console.warn(`ApexCharts: ${msg}`))
      if (!split.panels.length) return
      if (split.dropped > 0) {
        console.warn(
          `ApexCharts: trellis rendered ${split.panels.length} panels; ${split.dropped} more hidden by trellis.limit.`,
        )
      }

      // 1.5 Virtualization policy (P2): 'auto' virtualizes above the eager
      //     budget, true always, false never. Environments without
      //     IntersectionObserver warn and render eagerly.
      const vMode = this.cfg.virtualize ?? 'auto'
      let useVirtual =
        vMode === true ||
        (vMode === 'auto' && split.panels.length > EAGER_PANEL_BUDGET)
      if (useVirtual && !TrellisVirtual.supported()) {
        console.warn(
          'ApexCharts: trellis virtualization needs IntersectionObserver; rendering eagerly.',
        )
        useVirtual = false
      }
      this._virtualActive = useVirtual
      if (!useVirtual && split.panels.length > EAGER_PANEL_BUDGET) {
        console.warn(
          `ApexCharts: trellis with ${split.panels.length} panels renders eagerly; set trellis.virtualize (or trellis.limit).`,
        )
      }
      if (split.panels.length > HARD_PANEL_WARN) {
        console.warn(
          `ApexCharts: ${split.panels.length} trellis panels is a lot to read at once; consider trellis.limit.`,
        )
      }

      // 1.6 Type frames (P5): the shared hidden frames (histogram bin edges
      //     + count domain, violin bandwidth, heatmap color scale, bubble z,
      //     pie totals) that make the awkward types honest. `histogram` is a
      //     Config ALIAS (chart.type is rewritten to 'bar', the original kept
      //     in requestedType), so the frame gate must look at the alias.
      const frameType =
        w.config.chart.requestedType === 'histogram'
          ? 'histogram'
          : w.config.chart.type
      this._frames = buildTypeFrames(split, this.cfg, w.config, frameType)
      this._frames.warnings.forEach((msg) =>
        console.warn(`ApexCharts: ${msg}`),
      )

      // 2. Shared scales (through the EFFECTIVE y mode: a type frame can
      //    force 'shared' where group modes are meaningless).
      // With an overridden y domain the DRAWN values are not the data
      // (histogram counts, not observations), so the data's decimals must
      // not leak into the labels; the tick step's own decimals still apply.
      this._yLabelDecimals = this._frames.yExtentOverride
        ? 0
        : TrellisScales.maxYDecimals(split.panels)
      const scalesCfg =
        this._yMode() !== (this.cfg.scales?.y || 'shared')
          ? { ...this.cfg, scales: { ...(this.cfg.scales || {}), y: this._yMode() } }
          : this.cfg
      this.scales = TrellisScales.resolve(split, scalesCfg, {
        chartType: w.config.chart.type,
        userColors: this.ctx.opts && this.ctx.opts.colors,
        yExtentOverride: this._frames.yExtentOverride,
      })

      // 2.5 Renderer policy (P2): one uniform grid-level decision.
      this._panelRenderer = choosePanelRenderer(
        split,
        w.config,
        this.ctx.opts,
        RendererController._rendererRegistry.has('canvas'),
      )

      // 3. DOM skeleton + layout.
      this._buildSkeleton()
      const width = this._containerWidth()
      this._lastWidth = width
      this.layout = TrellisLayout.compute({
        panelCount: split.panels.length,
        containerWidth: width,
        cfg: this._layoutCfg(),
        hostHeight: this._hostHeight(),
      })
      this._applyGridStyle()
      this._buildCells()

      // 4. Mount panels. Any non-shared y mode needs the measured gutter
      //    pass: group modes push identical bounds WITHIN a group, but label
      //    widths still differ ACROSS groups.
      const independentY = this._yMode() !== 'shared'
      const wrap = /** @type {HTMLElement} */ (this.elWrap)

      if (useVirtual) {
        // P2: mount only the panels intersecting the viewport (one-row
        // margin); the observer's initial callback mounts the visible set.
        // Headers plus fixed-height skeletons keep page height and scroll
        // position stable, and the independent-y gutter is a monotone floor
        // maintained per mount (TrellisVirtual).
        this.virtual.start()
      } else {
        // Eager path. Independent-y trellises mount hidden so the gutter
        // pass (22a Q2/Q3) never flashes a misaligned first paint.
        if (independentY) wrap.style.visibility = 'hidden'

        for (let i = 0; i < this.panels.length; i++) {
          const panel = this.panels[i]
          if (panel.noMount) continue
          const opts = this._assemblePanelOptions(panel.index)
          const chart = new ApexCharts(
            /** @type {HTMLElement} */ (panel.el),
            opts,
          )
          panel.chart = chart
          await chart.render()
          this.ctx.events.fireEvent('panelMounted', [
            this.ctx,
            { key: panel.key, index: panel.index, chart },
          ])
        }

        // 5. Independent-y gutter pass: one measured minWidth, pushed to all,
        //    exact in one iteration by construction (max of measured widths).
        if (independentY) {
          await this._alignGutters()
          wrap.style.visibility = ''
        }
      }

      // 6. Shared chrome (title above, legend below, toolbar overlay), and
      //    the cross-panel crosshair sweep (the group's own fan-out assumes
      //    stacked charts; see TrellisSync.wireCrosshairs).
      this.chrome.buildTitle(/** @type {HTMLElement} */ (this._elChromeTop))
      this.chrome.buildToolbar(wrap)
      this.chrome.buildLegend(wrap)
      this.sync.wireCrosshairs(/** @type {HTMLElement} */ (this.elGrid))
      if ((this.cfg.tooltip || 'panel') === 'grid') {
        this.gridTooltip = new TrellisTooltip(this)
        this.gridTooltip.wire(/** @type {HTMLElement} */ (this.elGrid), wrap)
      }

      // 7. One ResizeObserver for the whole grid; panels have both
      //    redrawOn*Resize flags off, so this is the only relayout owner.
      addResizeListener(
        /** @type {HTMLElement} */ (this.ctx.el),
        this._resizeHandler,
      )

      this._mounted = true
      // The host never runs its own render pipeline, so nothing else would
      // ever flip its animationEnded flag; screenshot/e2e harnesses key on
      // it. Eager grids are done here; virtualized grids flip it when the
      // mount drain goes idle (TrellisVirtual).
      if (!useVirtual) w.globals.animationEnded = true
      this.ctx.events.fireEvent('trellisMounted', [
        this.ctx,
        { panels: this.getPanels() },
      ])
    } finally {
      this._rendering = false
    }
  }

  /** Container width the grid may use. */
  _containerWidth() {
    const el = /** @type {HTMLElement} */ (this.ctx.el)
    const rect = el.getBoundingClientRect()
    return rect.width || el.clientWidth || 800
  }

  /** Explicit numeric host height, if the user set one. */
  _hostHeight() {
    const h = this.w.config.chart && this.w.config.chart.height
    const n = typeof h === 'string' ? parseFloat(h) : h
    return typeof n === 'number' && isFinite(n) && n > 0 && String(h) !== 'auto'
      ? n
      : undefined
  }

  _buildSkeleton() {
    const el = /** @type {HTMLElement} */ (this.ctx.el)
    const wrap = BrowserAPIs.createElementNS(
      'http://www.w3.org/1999/xhtml',
      'div',
    )
    wrap.className = 'apexcharts-trellis'
    if (this.split && this.split.mode === '2d') {
      wrap.classList.add('apexcharts-trellis-2d')
    }
    wrap.id = `apexcharts-trellis${this.w.globals.chartID}`
    wrap.setAttribute('data-tooltip-mode', this.cfg.tooltip || 'panel')

    const chromeTop = BrowserAPIs.createElement('div')
    chromeTop.className = 'apexcharts-trellis-chrome'
    wrap.appendChild(chromeTop)
    this._elChromeTop = chromeTop

    const grid = BrowserAPIs.createElement('div')
    grid.className = 'apexcharts-trellis-grid'
    wrap.appendChild(grid)

    el.appendChild(wrap)
    this.elWrap = wrap
    this.elGrid = grid

    // The license enforcer and Destroy both address the chart through
    // w.dom.elWrap / baseEl; the trellis wrapper IS this host's canvas.
    this.w.dom.baseEl = el
    this.w.dom.elWrap = wrap
  }

  _applyGridStyle() {
    const grid = /** @type {HTMLElement} */ (this.elGrid)
    const ly = /** @type {import('./TrellisLayout').TrellisLayoutResult} */ (
      this.layout
    )
    grid.style.display = 'grid'
    const is2d = this.split && this.split.mode === '2d'
    const stripped = is2d && this.cfg.header?.show !== false
    // A 2-D grid with row strips reserves an auto-sized first column for the
    // row labels; the panel columns stay equal fractions, so alignment holds
    // whatever the strip measures.
    grid.style.gridTemplateColumns = stripped
      ? `auto repeat(${ly.cols}, minmax(0, 1fr))`
      : `repeat(${ly.cols}, minmax(0, 1fr))`
    grid.style.gap = `${ly.gap}px`
  }

  _buildCells() {
    const grid = /** @type {HTMLElement} */ (this.elGrid)
    const split = /** @type {import('./TrellisSplit').TrellisSplitResult} */ (
      this.split
    )
    const ly = /** @type {import('./TrellisLayout').TrellisLayoutResult} */ (
      this.layout
    )
    const is2d = split.mode === '2d'
    const stripped = is2d && this.cfg.header?.show !== false
    this._stripEls = []
    const emptyMode = this.cfg.emptyPanels || 'placeholder'

    // 2-D chrome (P4): column labels once across the top, row labels once
    // down the left, instead of a header per cell. Auto grid placement lays
    // them out: corner + C column strips, then per row (row strip + C cells).
    if (stripped) {
      const corner = BrowserAPIs.createElement('div')
      corner.className = 'apexcharts-trellis-corner'
      grid.appendChild(corner)
      this._stripEls.push(corner)
      ;(split.colKeys || []).forEach((ck, ci) => {
        const el = this.chrome.stripEl('column', ck, {
          index: ci,
          count: (split.colKeys || []).length,
        })
        grid.appendChild(el)
        this._stripEls.push(el)
      })
    }

    this.panels = []
    split.panels.forEach((slice, i) => {
      if (stripped && i % ly.cols === 0) {
        const ri = Math.floor(i / ly.cols)
        const el = this.chrome.stripEl(
          'row',
          (split.rowKeys || [])[ri] ?? '',
          { index: ri, count: (split.rowKeys || []).length },
        )
        grid.appendChild(el)
        this._stripEls.push(el)
      }

      const cell = BrowserAPIs.createElement('div')
      cell.className = 'apexcharts-trellis-cell'
      cell.setAttribute('data-key', slice.key)
      this._applyCellMutes(cell, ly.cells[i])
      if (!is2d) {
        this.chrome.buildHeader(cell, slice.key, {
          index: i,
          count: split.panels.length,
        })
      }
      const mount = BrowserAPIs.createElement('div')
      mount.className = 'apexcharts-trellis-panel'
      if (this._virtualActive) {
        // The skeleton reserves the exact panel height, so mounting and
        // unmounting never shifts page height or scroll position.
        mount.classList.add('apexcharts-trellis-skeleton')
        mount.style.minHeight = `${ly.panelH}px`
      }
      cell.appendChild(mount)

      // Empty combinations (P4): 'placeholder' mounts a REAL panel with an
      // all-null aligned series (same scale/geometry code as every other
      // panel, so alignment holds by construction) plus a quiet label;
      // 'skip' keeps the slot with a tinted blank; 'hide' keeps the slot
      // with nothing at all.
      const noMount = slice.empty && emptyMode !== 'placeholder'
      if (slice.empty) {
        cell.classList.add('apexcharts-trellis-cell-empty')
        if (emptyMode === 'hide') {
          cell.classList.add('apexcharts-trellis-cell-hidden')
        } else if (emptyMode === 'skip') {
          mount.classList.add('apexcharts-trellis-skeleton')
          mount.style.minHeight = `${ly.panelH}px`
        } else {
          const label = BrowserAPIs.createElement('div')
          label.className = 'apexcharts-trellis-empty-label'
          label.textContent =
            (this.w.config.noData && this.w.config.noData.text) || 'no data'
          cell.appendChild(label)
        }
      }

      grid.appendChild(cell)
      this.panels.push({
        key: slice.key,
        index: i,
        el: mount,
        cellEl: cell,
        chart: null,
        empty: slice.empty,
        noMount,
      })
    })
  }

  /**
   * Edge-label policy as CSS classes: a muted cell hides its labels' INK
   * (opacity), never their SPACE, so every panel keeps the identical plot
   * rectangle and a policy flip on resize re-renders nothing (22a).
   * @param {HTMLElement} cell
   * @param {import('./TrellisLayout').TrellisCell} c
   */
  _applyCellMutes(cell, c) {
    cell.classList.toggle('apexcharts-trellis-mute-x', !c.showXLabels)
    cell.classList.toggle('apexcharts-trellis-mute-y', !c.showYLabels)
  }

  /**
   * Build one panel's full options object: the user's own options (functions
   * preserved by reference), minus what the trellis owns, plus the shared
   * scale/color/geometry overrides. `trellis.panel(key, meta)` is applied
   * last, so a caller can override anything per panel.
   * @param {number} i
   * @param {{ noAnimation?: boolean }} [flags] noAnimation forces animations
   *   off regardless of policy (virtualized REMOUNTS: scrolling back must not
   *   replay the draw animation).
   * @returns {Record<string, any>}
   */
  _assemblePanelOptions(i, { noAnimation = false } = {}) {
    const w = this.w
    const split = /** @type {import('./TrellisSplit').TrellisSplitResult} */ (
      this.split
    )
    const scales = /** @type {ReturnType<typeof TrellisScales.resolve>} */ (
      this.scales
    )
    const ly = /** @type {import('./TrellisLayout').TrellisLayoutResult} */ (
      this.layout
    )
    const slice = split.panels[i]

    /** @type {Record<string, any>} */
    const base = Utils.clone(this.ctx.opts || {})
    delete base.trellis
    delete base.series
    delete base.responsive
    // Trellis-scoped annotations (P3): declared once on the host, filtered
    // per panel by `scope`, projected through each panel's own scale.
    if (base.annotations) {
      base.annotations = scopeAnnotations(base.annotations, slice.key)
    }

    const userChart = base.chart || {}
    const userEvents = userChart.events || {}
    const hostId = w.globals.chartID
    // Animation policy (P2): above the budget, mount animations default OFF
    // (a grid of simultaneous draw animations reads as noise and costs the
    // most), but an EXPLICIT user chart.animations.enabled always wins.
    const userAnimations = this.ctx.opts?.chart?.animations?.enabled
    const animationsOff =
      noAnimation ||
      (split.panels.length > ANIMATION_PANEL_BUDGET &&
        userAnimations === undefined)

    // Empty combination under 'placeholder' (P4): a real panel carrying one
    // valueless series aligned to the union x, so it runs the exact scale
    // and geometry code every sibling runs (zero-size marks for bar types:
    // the numeric bar pad only engages for series that draw).
    const isPlaceholder =
      slice.empty && (this.cfg.emptyPanels || 'placeholder') === 'placeholder'
    let panelSeries = isPlaceholder
      ? [placeholderSeries(split, { chartType: w.config.chart.type })]
      : slice.series

    // The radial value family (P5) takes a BARE values array, not the
    // axis-chart {name, data} form the split emits: unwrap, keeping the
    // POSITION of every value (a missing value becomes 0, never dropped:
    // value k must stay aligned with label k in every panel).
    const isValueSeries = ['pie', 'donut', 'polarArea', 'radialBar'].includes(
      w.config.chart.type,
    )
    if (isValueSeries) {
      panelSeries = panelSeries
        .flatMap((/** @type {any} */ s) =>
          Array.isArray(s.data) ? s.data : [],
        )
        .map((/** @type {any} */ v) =>
          typeof v === 'number' && isFinite(v) ? v : 0,
        )
    }

    /** @type {Record<string, any>} */
    const overrides = {
      chart: {
        id: `${hostId}-tp${i}`,
        // Group membership is unconditional: it powers tooltip and crosshair
        // sync, not only zoom. zoom:'none' disables the zoom TOOL below.
        group: this._groupId(),
        height: ly.panelH,
        width: '100%',
        // The trellis's single ResizeObserver is the only relayout owner
        // (22a Q4); a panel must never self-rerender on a resize tick.
        redrawOnParentResize: false,
        redrawOnWindowResize: false,
        // Core pads the chart's container by parentHeightOffset (default 15)
        // via an inline min-height; inside a height-budgeted grid cell that
        // slack de-syncs mounted cells from the skeleton reserve (the grid
        // gap is the breathing room here).
        parentHeightOffset: 0,
        toolbar: { show: false },
        zoom: {
          enabled: this.cfg.zoom !== 'none',
        },
        ...(animationsOff ? { animations: { enabled: false } } : {}),
        // Grid-level canvas auto-selection (P2): a uniform explicit backend,
        // decided once over the whole grid's mark count.
        ...(this._panelRenderer ? { renderer: this._panelRenderer } : {}),
        events: {
          ...userEvents,
          zoomed: this.sync.makeZoomedHandler(userEvents.zoomed),
          scrolled: this.sync.makeScrolledHandler(userEvents.scrolled),
        },
      },
      series: panelSeries,
      // Shared color: by series NAME across the whole trellis, so 'Revenue'
      // is the same color in every panel no matter the panel's series order.
      // The radial value family colors by LABEL index from the shared config
      // instead, which is identical across panels by construction.
      ...(isValueSeries
        ? {}
        : {
            colors: panelSeries.map((/** @type {any} */ s) =>
              scales.colorOf(s.name),
            ),
          }),
      // Headers replace per-panel titles; the shared legend replaces per-panel
      // legends. margin: 0 and floating both matter: an empty-string title
      // still charges its margin to the top gutter in Dimensions, and its
      // empty element still measures height + 5 unless floating.
      title: { text: '', margin: 0, floating: true },
      subtitle: { text: '', margin: 0, floating: true },
      legend: { show: false },
      // A placeholder has nothing to read: its zero marks must not caption.
      ...(isPlaceholder ? { tooltip: { enabled: false } } : {}),
      // A normal tooltip card is taller than a short panel, so on a dense
      // grid it covers the very line it is captioning. Below the threshold
      // the panels get the compact one-liner unless the user decided for
      // themselves. 'grid' mode composes its own card from the panels', so it
      // opts out: shrinking the source rows would shrink the grid card too.
      ...(ly.panelH < COMPACT_TOOLTIP_PANEL_H &&
      this.cfg.tooltip !== 'grid' &&
      typeof this.ctx.opts?.tooltip?.compact !== 'boolean'
        ? { tooltip: { compact: true } }
        : {}),
    }

    // Reclaim the standalone chart's vertical breathing room (see the
    // PANEL_PAD_RECLAIM constants), composed with the user's own padding.
    // Only for grid-based panels: circular/space-filling types have no
    // top gutter or x-axis slack to reclaim, and a sparkline zeroes its own
    // chrome (a negative pad there would shift the plot off the svg).
    const gridlessTypes = ['pie', 'donut', 'polarArea', 'radialBar', 'radar', 'treemap']
    if (
      !userChart.sparkline?.enabled &&
      !gridlessTypes.includes(w.config.chart.type)
    ) {
      const userPad = (base.grid && base.grid.padding) || {}
      const reclaimBottom =
        this.ctx.opts?.xaxis?.type === 'datetime'
          ? PANEL_PAD_RECLAIM_BOTTOM_DATETIME
          : PANEL_PAD_RECLAIM_BOTTOM
      overrides.grid = {
        padding: {
          top:
            (typeof userPad.top === 'number' ? userPad.top : 0) -
            PANEL_PAD_RECLAIM_TOP,
          bottom:
            (typeof userPad.bottom === 'number' ? userPad.bottom : 0) -
            reclaimBottom,
        },
      }
    }

    // Shared x domain (numeric/datetime): the equal-minX gate is what allows
    // the group tooltip sync at all. Skipped when x IS the value axis
    // (horizontal bars): there the union of the numeric CATEGORIES would
    // overwrite the value domain with category indices.
    const valueAxisIsX = this._valueAxisIsX()
    if (scales.x && !valueAxisIsX) {
      overrides.xaxis = { min: scales.x.min, max: scales.x.max }
    }
    // The panel's y domain per the scales mode (shared union, or its
    // row's/column's union, P4): identical bounds + tickAmount => identical
    // ticks, labels, gutters and plot rects (22a Q1). Merged INTO the user's
    // own yaxis entry (array or object): Utils.extend replaces arrays
    // wholesale, so a bare object override would drop the user's
    // formatter/label config.
    const yBounds = this._yBoundsFor(slice)
    if (yBounds) {
      const userYaxisRaw = Array.isArray(this.ctx.opts?.yaxis)
        ? this.ctx.opts.yaxis[0]
        : this.ctx.opts?.yaxis
      const userYaxis = Array.isArray(base.yaxis) ? base.yaxis[0] : base.yaxis
      // Identical bounds need identical label FORMATTING to yield identical
      // gutters: the library derives label decimals from each panel's OWN
      // data (decimalsInFloat only applies to float panels), so a
      // placeholder's zeros, or an integer-valued panel among float
      // siblings, would render "20" beside "20.00" and misalign. When the
      // user brought no formatter, push one uniform toFixed sized by the
      // union data's decimals and the tick step's own.
      /** @type {Record<string, any>} */
      let labelsPatch = {}
      if (typeof userYaxisRaw?.labels?.formatter !== 'function') {
        const step =
          (yBounds.max - yBounds.min) / Math.max(1, yBounds.tickAmount)
        const digits = Math.max(
          this._yLabelDecimals,
          TrellisScales.decimalCount(step),
        )
        labelsPatch = {
          labels: {
            formatter: (/** @type {any} */ val) =>
              typeof val === 'number' && isFinite(val)
                ? val.toFixed(digits)
                : val,
          },
        }
      }
      overrides.yaxis = Utils.extend(userYaxis || {}, {
        min: yBounds.min,
        max: yBounds.max,
        tickAmount: yBounds.tickAmount,
        ...labelsPatch,
      })
      delete base.yaxis
      if (valueAxisIsX) {
        overrides.xaxis = Utils.extend(
          overrides.xaxis || {},
          this._valueAxisXPatch(yBounds),
        )
      }
    }

    // Type frames (P5): the shared hidden frames every panel must draw in.
    // Deep-merged into the user's own plotOptions by Utils.extend below, so
    // only the shared keys (histogram range/binWidth, violin bandwidth,
    // heatmap colorScale min/max, bubble minZ/maxZ) are pinned.
    const frames = this._frames
    if (frames && frames.plotOptions) {
      overrides.plotOptions = Utils.clone(frames.plotOptions)
    }
    if (frames && frames.pieScaleOf) {
      const ratio = frames.pieScaleOf(slice.key)
      if (ratio !== null) {
        const userScale = this.ctx.opts?.plotOptions?.pie?.customScale
        overrides.plotOptions = Utils.extend(overrides.plotOptions || {}, {
          pie: {
            customScale:
              ratio * (typeof userScale === 'number' ? userScale : 1),
          },
        })
      }
    }

    let opts = Utils.extend(base, overrides)
    if (typeof this.cfg.panel === 'function') {
      const extra = this.cfg.panel(slice.key, { index: i, seriesNames: slice.seriesNames })
      if (extra && typeof extra === 'object') opts = Utils.extend(opts, extra)
    }
    return opts
  }

  /**
   * Independent-y gutter alignment (22a Q2): measure every panel's label
   * gutter, push the max as a shared `yaxis.labels.minWidth`. `max()` of the
   * measured widths makes the panels' `max(minWidth, measured)` resolve to
   * the same constant everywhere, so one iteration is exact by construction.
   * @returns {Promise<void>}
   */
  async _alignGutters() {
    const widths = this.panels
      .map((p) => p.chart?.w?.globals?.yLabelsCoords?.[0]?.width)
      .filter((v) => typeof v === 'number' && isFinite(v))
    if (!widths.length) return
    const isHorizontal = !!this.panels[0]?.chart?.w?.globals?.isBarHorizontal
    const labelPad = isHorizontal ? 0 : Y_LABEL_PAD
    const minWidth = Math.max(0, Math.max(...widths) - labelPad)
    await this._pushGutterFloor(minWidth)
  }

  /** The vertical-axis label pad inside the yLabelsCoords width (22a Q2). */
  _yLabelPad() {
    return Y_LABEL_PAD
  }

  /**
   * Push one shared gutter floor (`yaxis.labels.minWidth`) to every MOUNTED
   * panel. Shared by the eager one-pass alignment and the virtualized
   * monotone floor (TrellisVirtual bumps it when a newly mounted panel
   * measures wider).
   * @param {number} minWidth
   * @returns {Promise<void>}
   */
  async _pushGutterFloor(minWidth) {
    for (const p of this.panels) {
      if (!p.chart) continue
      await p.chart
        .updateOptions(
          { yaxis: yaxisPayload(p.chart, { labels: { minWidth } }) },
          false,
          false,
          false,
        )
        .catch(() => {})
    }
  }

  /** rAF-coalesced container resize -> single trellis-owned relayout. */
  _onContainerResize() {
    if (!this._mounted) return
    if (this._raf) return
    this._raf = requestAnimationFrame(() => {
      this._raf = 0
      const width = this._containerWidth()
      if (Math.round(width) === Math.round(this._lastWidth)) return
      this._lastWidth = width
      if (this._promotedKey) {
        // While promoted the grid layout is suspended; the promoted panel
        // just re-measures its new width.
        const p = this.panels.find((p) => p.key === this._promotedKey)
        if (p && p.chart) p.chart.updateOptions({}, false, false, false).catch(() => {})
        return
      }
      this._relayout(width)
    })
  }

  /**
   * Panel promotion (P3): expand one panel to the grid's full width, park the
   * rest (their cells hide; virtualized ones unmount via the observer), and
   * show a breadcrumb to come back. Zoom/legend state is untouched: parked
   * eager panels stay alive, parked virtual panels stash and restore.
   * @param {string} key
   * @returns {Promise<void>}
   */
  async promote(key) {
    const panel = this.panels.find((p) => p.key === String(key))
    if (!panel || panel.noMount || !this._mounted) return
    if (this._promotedKey === panel.key) return
    if (this._promotedKey) await this.restorePromotion()

    this._promotedKey = panel.key
    const ly = this.layout
    const gridH = this.elGrid ? this.elGrid.getBoundingClientRect().height : 0
    // The grid's own footprint, bounded to something a single chart wears
    // well: never taller than the grid it replaces, never a sliver.
    const promotedH = Math.round(
      Math.max(280, Math.min(560, gridH || (ly ? ly.panelH * 2.4 : 420))),
    )

    this.elWrap?.classList.add('apexcharts-trellis-promoting')
    this.panels.forEach((p) => {
      if (!p.cellEl) return
      const promoted = p === panel
      p.cellEl.classList.toggle('apexcharts-trellis-cell-promoted', promoted)
      p.cellEl.classList.toggle('apexcharts-trellis-cell-parked', !promoted)
    })
    // 2-D strips park with the grid (the promoted panel names itself).
    this._stripEls.forEach((el) =>
      el.classList.add('apexcharts-trellis-cell-parked'),
    )
    this.chrome.buildBreadcrumb(
      /** @type {HTMLElement} */ (this._elChromeTop),
      panel.key,
      () => this.restorePromotion(),
    )

    if (!panel.chart && this._virtualActive) {
      // A promotion by API of an offscreen panel: mount it through the
      // normal drain (it now intersects, its cell being the only one shown).
      panel.wantMounted = true
      this.virtual._dirty.add(panel)
      this.virtual._schedule()
    }
    if (panel.el) panel.el.style.minHeight = `${promotedH}px`
    if (panel.chart) {
      await panel.chart
        .updateOptions({ chart: { height: promotedH } }, false, false, false)
        .catch(() => {})
    }
    this.ctx.events.fireEvent('panelPromoted', [
      this.ctx,
      { key: panel.key, chart: panel.chart },
    ])
  }

  /** Restore the grid from a promotion. @returns {Promise<void>} */
  async restorePromotion() {
    if (!this._promotedKey) return
    const panel = this.panels.find((p) => p.key === this._promotedKey)
    this._promotedKey = null
    this.elWrap?.classList.remove('apexcharts-trellis-promoting')
    this.panels.forEach((p) => {
      if (!p.cellEl) return
      p.cellEl.classList.remove('apexcharts-trellis-cell-promoted')
      p.cellEl.classList.remove('apexcharts-trellis-cell-parked')
    })
    this._stripEls.forEach((el) =>
      el.classList.remove('apexcharts-trellis-cell-parked'),
    )
    this.chrome.removeBreadcrumb()
    const ly = this.layout
    if (panel && panel.el && ly) panel.el.style.minHeight = `${ly.panelH}px`
    if (panel && panel.chart && ly) {
      await panel.chart
        .updateOptions({ chart: { height: ly.panelH } }, false, false, false)
        .catch(() => {})
    }
    // Re-derive the grid for the current width (mutes, heights, observer).
    this._lastWidth = 0
    this._relayout(this._containerWidth())
    this._lastWidth = this._containerWidth()
    this.ctx.events.fireEvent('panelRestored', [
      this.ctx,
      { key: panel ? panel.key : null },
    ])
  }

  /**
   * Recompute the grid for a new width. A changed column count re-derives the
   * edge policy (which panels sit on the bottom row changed) by toggling cell
   * classes; panel geometry updates are one non-fanout updateOptions each.
   * @param {number} width
   */
  _relayout(width) {
    const split = this.split
    if (!split) return
    const prevH = this.layout ? this.layout.panelH : 0
    this.layout = TrellisLayout.compute({
      panelCount: split.panels.length,
      containerWidth: width,
      cfg: this._layoutCfg(),
      hostHeight: this._hostHeight(),
    })
    this._applyGridStyle()
    const ly = this.layout
    this.panels.forEach((p, i) => {
      if (p.cellEl) this._applyCellMutes(p.cellEl, ly.cells[i])
      if (p.el && this._virtualActive) p.el.style.minHeight = `${ly.panelH}px`
      if (!p.chart) return
      const payload =
        ly.panelH !== prevH ? { chart: { height: ly.panelH } } : {}
      // Even a same-height pass must run: the panel re-measures its new cell
      // width (22a Q4 measured this at ~1.6 ms per panel).
      p.chart.updateOptions(payload, false, false, false).catch(() => {})
    })
    // The observer's one-row look-ahead margin tracks the panel height, and
    // a recolumn changes which cells intersect; re-observing reconciles both.
    if (this._virtualActive) this.virtual.refresh()
  }

  /**
   * Host updateSeries: re-split against the SAME panel key set and push each
   * panel its new slice (plus refreshed shared domains) in one update. A
   * changed key set (panels appearing/disappearing) is a structural change:
   * torn down and re-rendered.
   * @param {any[]} newSeries
   * @param {boolean} [animate]
   * @returns {Promise<any>}
   */
  async updateSeries(newSeries, animate = true) {
    const w = this.w
    if (Array.isArray(this.cfg.data) && this.cfg.data.length) {
      console.warn(
        'ApexCharts: this trellis renders from trellis.data; update it via updateOptions({ trellis: { data } }).',
      )
      return Promise.resolve()
    }
    w.config.series = newSeries
    if (this.ctx.opts) this.ctx.opts.series = newSeries
    if (!this._mounted) return this.ctx.render()

    const nextSplit = splitSeries(newSeries || [], this.cfg)
    const sameKeys =
      nextSplit.panels.length === this.panels.length &&
      nextSplit.panels.every((p, i) => p.key === this.panels[i].key)
    if (!sameKeys) {
      this.teardown()
      return this.ctx.render()
    }

    this.split = nextSplit
    // Type frames (P5) are data-derived (bin edges, color extent, z extent,
    // pie totals): recompute them with the new data before the pushes.
    this._frames = buildTypeFrames(
      nextSplit,
      this.cfg,
      w.config,
      w.config.chart.requestedType === 'histogram'
        ? 'histogram'
        : w.config.chart.type,
    )
    const scalesCfg =
      this._yMode() !== (this.cfg.scales?.y || 'shared')
        ? { ...this.cfg, scales: { ...(this.cfg.scales || {}), y: this._yMode() } }
        : this.cfg
    this.scales = TrellisScales.resolve(nextSplit, scalesCfg, {
      chartType: w.config.chart.type,
      userColors: this.ctx.opts && this.ctx.opts.colors,
      yExtentOverride: this._frames.yExtentOverride,
    })
    const scales = this.scales
    const frames = this._frames

    const isValueSeries = ['pie', 'donut', 'polarArea', 'radialBar'].includes(
      w.config.chart.type,
    )
    const pushes = this.panels.map((p, i) => {
      if (!p.chart) return Promise.resolve()
      /** @type {Record<string, any>} */
      const payload = {
        // The radial value family takes a BARE values array (see the same
        // unwrap in _assemblePanelOptions).
        series: isValueSeries
          ? nextSplit.panels[i].series
              .flatMap((/** @type {any} */ s) =>
                Array.isArray(s.data) ? s.data : [],
              )
              .map((/** @type {any} */ v) =>
                typeof v === 'number' && isFinite(v) ? v : 0,
              )
          : nextSplit.panels[i].series,
      }
      const valueAxisIsX = this._valueAxisIsX()
      if (scales.x && !valueAxisIsX) {
        payload.xaxis = { min: scales.x.min, max: scales.x.max }
      }
      const yBounds = this._yBoundsFor(nextSplit.panels[i])
      if (yBounds) {
        payload.yaxis = yaxisPayload(p.chart, {
          min: yBounds.min,
          max: yBounds.max,
          tickAmount: yBounds.tickAmount,
        })
        if (valueAxisIsX) {
          payload.xaxis = Utils.extend(
            payload.xaxis || {},
            this._valueAxisXPatch(yBounds),
          )
        }
      }
      if (frames.plotOptions) {
        payload.plotOptions = Utils.clone(frames.plotOptions)
      }
      if (frames.pieScaleOf) {
        const ratio = frames.pieScaleOf(nextSplit.panels[i].key)
        if (ratio !== null) {
          const userScale = this.ctx.opts?.plotOptions?.pie?.customScale
          payload.plotOptions = Utils.extend(payload.plotOptions || {}, {
            pie: {
              customScale:
                ratio * (typeof userScale === 'number' ? userScale : 1),
            },
          })
        }
      }
      return p.chart
        .updateOptions(payload, false, animate, false)
        .catch(() => {})
    })
    return Promise.all(pushes)
  }

  /** @returns {Array<{ key: string, index: number, chart: any|null, el: HTMLElement|null }>} */
  getPanels() {
    return this.panels.map((p) => ({
      key: p.key,
      index: p.index,
      chart: p.chart,
      el: p.cellEl,
    }))
  }

  /**
   * @param {string} key
   * @returns {any|null} the panel's ApexCharts instance
   */
  getPanel(key) {
    const p = this.panels.find((p) => p.key === String(key))
    return p ? p.chart : null
  }

  /**
   * Destroy every panel (each unregisters itself from Apex._chartInstances),
   * disconnect the observers, and remove the trellis DOM.
   */
  teardown() {
    this.virtual.stop()
    this._virtualActive = false
    this._panelRenderer = null
    this._promotedKey = null
    this._frames = null
    this.chrome.destroyGradientLegend()
    if (this.gridTooltip) {
      this.gridTooltip.destroy()
      this.gridTooltip = null
    }
    if (this._raf) {
      cancelAnimationFrame(this._raf)
      this._raf = 0
    }
    if (Environment.isBrowser() && this.ctx.el) {
      removeResizeListener(
        /** @type {Element} */ (this.ctx.el),
        this._resizeHandler,
      )
    }
    this.panels.forEach((p) => {
      if (p.chart) {
        try {
          p.chart.destroy()
        } catch (e) {
          // A panel that never finished mounting can throw on teardown;
          // continue so every other panel is still destroyed.
        }
      }
      p.chart = null
    })
    this.panels = []
    this._stripEls = []
    if (this.elWrap && this.elWrap.parentNode) {
      this.elWrap.parentNode.removeChild(this.elWrap)
    }
    this.elWrap = null
    this.elGrid = null
    this._mounted = false
    // allow a fresh render() on the host after teardown
    this.ctx._renderPromise = null
  }
}