constructor(options, el) {
    this.abortController = new AbortController();

    const scriptTreemapData = options.lhr.audits['script-treemap-data'].details;
    if (!scriptTreemapData || scriptTreemapData.type !== 'treemap-data') {
      throw new Error('missing script-treemap-data');
    }

    // Since ~Apr 2025, script-treemap-data contains transfer size data. If present,
    // the app displays all byte sizes in terms of transfer size. Otherwise, resource
    // size is used.
    this.showTransferSize = scriptTreemapData.nodes.every(node => node.encodedBytes !== undefined);
    /** @type {'encodedBytes'|'resourceBytes'} */
    this.defaultPartitionBy = this.showTransferSize ? 'encodedBytes' : 'resourceBytes';
    // DevTools RPP doesn't have unused bytes in the info it sends.
    this.showUnusedBytes = scriptTreemapData.nodes.some(node => node.unusedBytes !== undefined);

    /** @type {{[group: string]: LH.Treemap.Node[]}} */
    this.depthOneNodesByGroup = {
      scripts: scriptTreemapData.nodes,
    };

    /**
     * Used to associate every node with a particular depth one node,
     * so that all nodes from the same depth one node can be colored
     * the same.
     * @type {WeakMap<LH.Treemap.Node, LH.Treemap.Node>}
     */
    this.nodeToDepthOneNodeMap = new WeakMap();
    for (const depthOneNodes of Object.values(this.depthOneNodesByGroup)) {
      for (const depthOneNode of depthOneNodes) {
        TreemapUtil.walk(depthOneNode, node => this.nodeToDepthOneNodeMap.set(node, depthOneNode));
      }
    }

    /** @type {WeakMap<LH.Treemap.Node, LH.Treemap.NodePath>} */
    this.nodeToPathMap = new WeakMap();

    /**
     * Used to store the size and unused bytes of each node, multiplied by
     * compression ratio depending on the state of `this.showTransferSize`.
     * Used to avoid mutating the input data.
     * @type {WeakMap<LH.Treemap.Node, {size: number, resourceBytes: number, encodedBytes?: number, unusedBytes?: number}>}
     */
    this.nodeToSizesMap = new WeakMap();

    // Priority breakdown:
    // 1) `mainDocumentUrl`: This is what we want post-10.0 for navigation reports.
    // 2) `finalUrl`: This is what we want pre-10.0 for navigation reports.
    // 3) `finalDisplayedUrl`: Timespan and snapshot reports don't have either of the above URLs, so use this one for display / origin check purposes.
    const documentUrlString = options.lhr.mainDocumentUrl ||
      options.lhr.finalUrl ||
      options.lhr.finalDisplayedUrl;

    this.documentUrl = new URL(documentUrlString);
    this.el = el;
    this.getHueForD1NodeName = TreemapUtil.stableHasher(TreemapUtil.COLOR_HUES);

    // These depth one node uses the network URL for the name, but we want
    // to elide common parts of the URL so text fits better in the UI.
    for (const node of this.depthOneNodesByGroup.scripts) {
      try {
        const url = new URL(node.name);
        node.name = TreemapUtil.elideSameOrigin(url, this.documentUrl);
        const isInlineHtmlNode =
          node.children?.every(child => child.name.startsWith('(inline)')) ||
          // Backport for treemap data that does not add the "(inline)" prefix to each inline script.
          // This is pre-10.0 when the `finalUrl` represented the main document url.
          url.href === this.documentUrl.href;
        if (isInlineHtmlNode) {
          node.name += ' (inline)';
        }
      } catch {}
    }

    this.initialViewModeId = options.initialView;

    /* eslint-disable no-unused-expressions */
    /** @type {LH.Treemap.Node} */
    this.currentTreemapRoot;
    /** @type {LH.Treemap.ViewMode} */
    this.currentViewMode;
    /** @type {LH.Treemap.Selector} */
    this.selector;
    /** @type {LH.Treemap.ViewMode[]} */
    this.viewModes;
    /** @type {RenderState=} */
    this.previousRenderState;
    /** @type {WeakMap<HTMLElement, NodeWithElement|NodeWithElement[]>} */
    this.tableRowToNodeMap = new WeakMap();
    /** @type {WebTreeMap} */
    this.treemap;
    /*  eslint-enable no-unused-expressions */

    const urlEl = dom.find('a.lh-header--url');
    urlEl.textContent = this.documentUrl.toString();
    urlEl.href = this.documentUrl.toString();

    this.viewModeSelector = this.createViewModeSelector();
    this.bundleSelector = this.createBundleSelector();
    this.toggleTable(window.innerWidth >= 600);
    this.initListeners();
    this.setSelector({type: 'group', value: 'scripts'});
    const rootBytesEl = dom.find('span.lh-header--url-bytes');
    rootBytesEl.textContent =
      TreemapUtil.i18n.formatBytesWithBestUnit(this.getNodeSizes(this.currentTreemapRoot).size);
    rootBytesEl.title = this.showTransferSize ?
      TreemapUtil.strings.transferBytesLabel :
      TreemapUtil.strings.resourceBytesLabel;
    this.render();
  }
@param {LH.Treemap.Options} options
@param {HTMLElement} el