import { selection, select } from 'd3-selection';
import { max, min, sum, cumsum } from 'd3-array';
import { tree, stratify } from 'd3-hierarchy';
import { zoom, zoomIdentity } from 'd3-zoom';
import { flextree } from 'd3-flextree';
import { linkHorizontal } from 'd3-shape';

const d3 = {
  selection,
  select,
  max,
  min,
  sum,
  cumsum,
  tree,
  stratify,
  zoom,
  zoomIdentity,
  linkHorizontal,
};
export class OrgChart {
  constructor() {
    // Exposed variables
    const attrs = {
      id: `ID${Math.floor(Math.random() * 1000000)}`, // Id for event handlings
      firstDraw: true,
      svgWidth: 800,
      svgHeight: window.innerHeight - 100,
      scaleExtent: [0.001, 20],
      container: 'body',
      defaultTextFill: '#2C3E50',
      defaultFont: 'Helvetica',
      ctx: document.createElement('canvas').getContext('2d'),
      data: null,
      duration: 400,
      setActiveNodeCentered: true,
      expandLevel: 0,
      compact: true,
      rootMargin: 40,
      nodeDefaultBackground: 'none',
      connections: [],
      lastTransform: { x: 0, y: 0, k: 1 },
      nodeId: (d) => d.nodeId || d.id,
      parentNodeId: (d) => d.parentNodeId || d.parentId,
      backgroundColor: 'none',
      zoomBehavior: null,
      defs: function (state, visibleConnections) {
        return `<defs>
                    ${visibleConnections
                      .map((conn) => {
                        const labelWidth = this.getTextWidth(conn.label, {
                          ctx: state.ctx,
                          fontSize: 2,
                          defaultFont: state.defaultFont,
                        });
                        return `
                       <marker id="${conn.from + '_' + conn.to}" refX="${
                          conn._source.x < conn._target.x ? -7 : 7
                        }" refY="5" markerWidth="500"  markerHeight="500"  orient="${
                          conn._source.x < conn._target.x
                            ? 'auto'
                            : 'auto-start-reverse'
                        }" >
                       <rect rx=0.5 width=${
                         conn.label ? labelWidth + 3 : 0
                       } height=3 y=1  fill="#152785"></rect>
                       <text font-size="2px" x=1 fill="white" y=3>${
                         conn.label || ''
                       }</text>
                       </marker>

                       <marker id="arrow-${
                         conn.from + '_' + conn.to
                       }"  markerWidth="500"  markerHeight="500"  refY="2"  refX="1" orient="${
                          conn._source.x < conn._target.x
                            ? 'auto'
                            : 'auto-start-reverse'
                        }" >
                       <path transform="translate(0)" d='M0,0 V4 L2,2 Z' fill='#152785' />
                       </marker>
                    `;
                      })
                      .join('')}
                    </defs>
                    `;
      },
      connectionsUpdate: function (d, i, arr) {
        d3.select(this)
          .attr('stroke', (d) => '#152785')
          .attr('stroke-linecap', 'round')
          .attr('stroke-width', (d) => '5')
          .attr('pointer-events', 'none')
          .attr('marker-start', (d) => `url(#${d.from + '_' + d.to})`)
          .attr('marker-end', (d) => `url(#arrow-${d.from + '_' + d.to})`);
      },
      linkUpdate: function (d, i, arr) {
        d3.select(this)
          .attr('stroke', (d) =>
            d.data._upToTheRootHighlighted ? '#152785' : 'lightgray',
          )
          .attr('stroke-width', (d) =>
            d.data._upToTheRootHighlighted ? 5 : 2,
          );

        if (d.data._upToTheRootHighlighted) {
          d3.select(this).raise();
        }
      },
      nodeUpdate: function (d, i, arr) {
        d3.select(this)
          .select('.node-rect')
          .attr('stroke', (d) =>
            d.data._highlighted || d.data._upToTheRootHighlighted
              ? '#152785'
              : 'none',
          )
          .attr(
            'stroke-width',
            d.data._highlighted || d.data._upToTheRootHighlighted ? 10 : 1,
          );
      },

      nodeWidth: (d3Node) => 250,
      nodeHeight: (d) => 150,
      siblingsMargin: (d3Node) => 20,
      childrenMargin: (d) => 60,
      neightbourMargin: (n1, n2) => 80,
      compactMarginPair: (d) => 100,
      compactMarginBetween: (d3Node) => 20,
      onNodeClick: (d) => d,
      linkGroupArc: d3
        .linkHorizontal()
        .x((d) => d.x)
        .y((d) => d.y),
      // ({ source, target }) => {
      //     return
      //     return `M ${source.x} , ${source.y} Q ${(source.x + target.x) / 2 + 100},${source.y-100}  ${target.x}, ${target.y}`;
      // },
      nodeContent: (
        d,
      ) => `<div style="padding:5px;font-size:10px;">Sample Node(id=${d.id}), override using <br/> <br/>
            <code>chart<br/>
            &nbsp;.nodeContent({data}=>{ <br/>
             &nbsp;&nbsp;&nbsp;&nbsp;return '' // Custom HTML <br/>
             &nbsp;})</code>
             <br/> <br/>
             Or check different <a href="https://github.com/bumbeishvili/org-chart#jump-to-examples" target="_blank">layout examples</a>

             </div>`,
      layout: 'top', // top, left,right, bottom
      buttonContent: ({ node, state }) => {
        const icons = {
          left: (d) =>
            d
              ? `<div style="margin-top:-10px;line-height:1.2;font-size:25px;height:22px">‹</div>`
              : `<div style="margin-top:-10px;font-size:25px;height:23px">›</div>`,
          bottom: (d) =>
            d
              ? `<div style="margin-top:-20px;font-size:25px">ˬ</div>`
              : `<div style="margin-top:0px;line-height:1.2;height:11px;font-size:25px">ˆ</div>`,
          right: (d) =>
            d
              ? `<div style="margin-top:-10px;font-size:25px;height:23px">›</div>`
              : `<div style="margin-top:-10px;line-height:1.2;font-size:25px;height:22px">‹</div>`,
          top: (d) =>
            d
              ? `<div style="margin-top:0px;line-height:1.2;height:11px;font-size:25px">ˆ</div>`
              : `<div style="margin-top:-20px;font-size:25px">ˬ</div>`,
        };
        return `<div style="border-radius:3px;padding:3px;font-size:10px;margin:auto auto;background-color:lightgray"> ${icons[
          state.layout
        ](node.children)}  </div>`;
      },
      layoutBindings: {
        left: {
          nodeLeftX: (node) => node.width / 0.4,
          nodeRightX: (node) => node.width,
          nodeTopY: (node) => -node.height / 2,
          nodeBottomY: (node) => node.height / 2,
          nodeJoinX: (node) => node.x + node.width,
          nodeJoinY: (node) => node.y - node.height / 2,
          linkJoinX: (node) => node.x + node.width,
          linkJoinY: (node) => node.y,
          linkX: (node) => node.x,
          linkY: (node) => node.y,
          linkCompactXStart: (node) => node.x + node.width / 2, //node.x + (node.compactEven ? node.width / 2 : -node.width / 2),
          linkCompactYStart: (node) =>
            node.y + (node.compactEven ? node.height / 2 : -node.height / 2),
          compactLinkMidX: (node, state) => node.firstCompactNode.x, // node.firstCompactNode.x + node.firstCompactNode.flexCompactDim[0] / 4 + state.compactMarginPair(node) / 4,
          compactLinkMidY: (node, state) =>
            node.firstCompactNode.y +
            node.firstCompactNode.flexCompactDim[0] / 4 +
            state.compactMarginPair(node) / 4,
          linkParentX: (node) => node.parent.x + node.parent.width,
          linkParentY: (node) => node.parent.y,
          buttonX: (node) => node.width,
          buttonY: (node) => node.height / 2,
          centerTransform: ({ root, rootMargin, centerY, scale, centerX }) =>
            `translate(${rootMargin},${centerY}) scale(${scale})`,
          compactDimension: {
            sizeColumn: (node) => node.height,
            sizeRow: (node) => node.width,
            reverse: (arr) => arr.slice().reverse(),
          },
          nodeFlexSize: ({
            height,
            width,
            siblingsMargin,
            childrenMargin,
            state,
            node,
          }) => {
            if (state.compact && node.flexCompactDim) {
              const result = [node.flexCompactDim[0], node.flexCompactDim[1]];
              return result;
            }
            return [height + siblingsMargin, width + childrenMargin];
          },
          zoomTransform: ({ centerY, scale }) =>
            `translate(${0},${centerY}) scale(${scale})`,
          diagonal: this.hdiagonal.bind(this),
          swap: (d) => {
            const x = d.x;
            d.x = d.y;
            d.y = x;
          },
          nodeUpdateTransform: ({ x, y, width, height }) =>
            `translate(${x},${y - height / 2})`,
        },
        top: {
          nodeLeftX: (node) => -node.width / 2,
          nodeRightX: (node) => node.width / 2,
          nodeTopY: (node) => 0,
          //nodeTopY: (node) => node.height / 0.3,
          nodeBottomY: (node) => node.height,
          nodeJoinX: (node) => node.x - node.width / 2,
          nodeJoinY: (node) => node.y + node.height,
          linkJoinX: (node) => node.x,
          linkJoinY: (node) => node.y + node.height,
          linkCompactXStart: (node) =>
            node.x + (node.compactEven ? node.width / 2 : -node.width / 2),
          linkCompactYStart: (node) => node.y + node.height / 2,
          compactLinkMidX: (node, state) =>
            node.firstCompactNode.x +
            node.firstCompactNode.flexCompactDim[0] / 4 +
            state.compactMarginPair(node) / 4,
          compactLinkMidY: (node) => node.firstCompactNode.y,
          compactDimension: {
            sizeColumn: (node) => node.width,
            sizeRow: (node) => node.height,
            reverse: (arr) => arr,
          },
          linkX: (node) => node.x,
          linkY: (node) => node.y,
          linkParentX: (node) => node.parent.x,
          linkParentY: (node) => node.parent.y + node.parent.height,
          buttonX: (node) => node.width / 2,
          buttonY: (node) => node.height,
          centerTransform: ({ root, rootMargin, centerY, scale, centerX }) =>
            `translate(${centerX},${rootMargin}) scale(${scale})`,
          nodeFlexSize: ({
            height,
            width,
            siblingsMargin,
            childrenMargin,
            state,
            node,
            compactViewIndex,
          }) => {
            if (state.compact && node.flexCompactDim) {
              const result = [node.flexCompactDim[0], node.flexCompactDim[1]];
              return result;
            }
            return [width + siblingsMargin, height + childrenMargin];
          },
          zoomTransform: ({ centerX, scale }) =>
            `translate(${centerX},0}) scale(${scale})`,
          diagonal: this.diagonal.bind(this),
          swap: (d) => {},
          nodeUpdateTransform: ({ x, y, width, height }) =>
            `translate(${x - width / 2},${y})`,
        },
        bottom: {
          nodeLeftX: (node) => -node.width / 2,
          nodeRightX: (node) => node.width / 2,
          nodeTopY: (node) => -node.height,
          nodeBottomY: (node) => 0,
          nodeJoinX: (node) => node.x - node.width / 2,
          nodeJoinY: (node) => node.y - node.height - node.height,
          linkJoinX: (node) => node.x,
          linkJoinY: (node) => node.y - node.height,
          linkCompactXStart: (node) =>
            node.x + (node.compactEven ? node.width / 2 : -node.width / 2),
          linkCompactYStart: (node) => node.y - node.height / 2,
          compactLinkMidX: (node, state) =>
            node.firstCompactNode.x +
            node.firstCompactNode.flexCompactDim[0] / 4 +
            state.compactMarginPair(node) / 4,
          compactLinkMidY: (node) => node.firstCompactNode.y,
          linkX: (node) => node.x,
          linkY: (node) => node.y,
          compactDimension: {
            sizeColumn: (node) => node.width,
            sizeRow: (node) => node.height,
            reverse: (arr) => arr,
          },
          linkParentX: (node) => node.parent.x,
          linkParentY: (node) => node.parent.y - node.parent.height,
          buttonX: (node) => node.width / 2,
          buttonY: (node) => 0,
          centerTransform: ({
            root,
            rootMargin,
            centerY,
            scale,
            centerX,
            chartHeight,
          }) =>
            `translate(${centerX},${chartHeight - rootMargin}) scale(${scale})`,
          nodeFlexSize: ({
            height,
            width,
            siblingsMargin,
            childrenMargin,
            state,
            node,
          }) => {
            if (state.compact && node.flexCompactDim) {
              const result = [node.flexCompactDim[0], node.flexCompactDim[1]];
              return result;
            }
            return [width + siblingsMargin, height + childrenMargin];
          },
          zoomTransform: ({ centerX, scale }) =>
            `translate(${centerX},0}) scale(${scale})`,
          diagonal: this.diagonal.bind(this),
          swap: (d) => {
            d.y = -d.y;
          },
          nodeUpdateTransform: ({ x, y, width, height }) =>
            `translate(${x - width / 2},${y - height})`,
        },
        right: {
          nodeLeftX: (node) => -node.width,
          nodeRightX: (node) => 0,
          nodeTopY: (node) => -node.height / 2,
          nodeBottomY: (node) => node.height / 2,
          nodeJoinX: (node) => node.x - node.width - node.width,
          nodeJoinY: (node) => node.y - node.height / 2,
          linkJoinX: (node) => node.x - node.width,
          linkJoinY: (node) => node.y,
          linkX: (node) => node.x,
          linkY: (node) => node.y,
          linkParentX: (node) => node.parent.x - node.parent.width,
          linkParentY: (node) => node.parent.y,
          buttonX: (node) => 0,
          buttonY: (node) => node.height / 2,
          linkCompactXStart: (node) => node.x - node.width / 2, //node.x + (node.compactEven ? node.width / 2 : -node.width / 2),
          linkCompactYStart: (node) =>
            node.y + (node.compactEven ? node.height / 2 : -node.height / 2),
          compactLinkMidX: (node, state) => node.firstCompactNode.x, // node.firstCompactNode.x + node.firstCompactNode.flexCompactDim[0] / 4 + state.compactMarginPair(node) / 4,
          compactLinkMidY: (node, state) =>
            node.firstCompactNode.y +
            node.firstCompactNode.flexCompactDim[0] / 4 +
            state.compactMarginPair(node) / 4,
          centerTransform: ({
            root,
            rootMargin,
            centerY,
            scale,
            centerX,
            chartWidth,
          }) =>
            `translate(${chartWidth - rootMargin},${centerY}) scale(${scale})`,
          nodeFlexSize: ({
            height,
            width,
            siblingsMargin,
            childrenMargin,
            state,
            node,
          }) => {
            if (state.compact && node.flexCompactDim) {
              const result = [node.flexCompactDim[0], node.flexCompactDim[1]];
              return result;
            }
            return [height + siblingsMargin, width + childrenMargin];
          },
          compactDimension: {
            sizeColumn: (node) => node.height,
            sizeRow: (node) => node.width,
            reverse: (arr) => arr.slice().reverse(),
          },
          zoomTransform: ({ centerY, scale }) =>
            `translate(${0},${centerY}) scale(${scale})`,
          diagonal: this.hdiagonal.bind(this),
          swap: (d) => {
            const x = d.x;
            d.x = -d.y;
            d.y = x;
          },
          nodeUpdateTransform: ({ x, y, width, height }) =>
            `translate(${x - width},${y - height / 2})`,
        },
      },
    };

    this.getChartState = () => attrs;

    // Dynamically set getter and setter functions for Chart class
    Object.keys(attrs).forEach((key) => {
      //@ts-ignore
      this[key] = function (_) {
        if (!arguments.length) {
          return attrs[key];
        } else {
          attrs[key] = _;
        }
        return this;
      };
    });

    this.initializeEnterExitUpdatePattern();
  }

  initializeEnterExitUpdatePattern() {
    d3.selection.prototype.patternify = function (params) {
      var container = this;
      var selector = params.selector;
      var elementTag = params.tag;
      var data = params.data || [selector];

      // Pattern in action
      var selection = container.selectAll('.' + selector).data(data, (d, i) => {
        if (typeof d === 'object') {
          if (d.id) {
            return d.id;
          }
        }
        return i;
      });
      selection.exit().remove();
      selection = selection.enter().append(elementTag).merge(selection);
      selection.attr('class', selector);
      return selection;
    };
  }

  // This method retrieves passed node's children IDs (including node)
  getNodeChildren({ data, children, _children }, nodeStore) {
    // Store current node ID
    nodeStore.push(data);

    // Loop over children and recursively store descendants id (expanded nodes)
    if (children) {
      children.forEach((d) => {
        this.getNodeChildren(d, nodeStore);
      });
    }

    // Loop over _children and recursively store descendants id (collapsed nodes)
    if (_children) {
      _children.forEach((d) => {
        this.getNodeChildren(d, nodeStore);
      });
    }

    // Return result
    return nodeStore;
  }

  // This method can be invoked via chart.setZoomFactor API, it zooms to particulat scale
  initialZoom(zoomLevel) {
    const attrs = this.getChartState();
    attrs.lastTransform.k = zoomLevel;
    return this;
  }

  render() {
    //InnerFunctions which will update visuals
    const attrs = this.getChartState();
    if (!attrs.data || attrs.data.length == 0) {
      return this;
    }

    //Drawing containers
    const container = d3.select(attrs.container);
    const containerRect = container.node().getBoundingClientRect();
    if (containerRect.width > 0) attrs.svgWidth = containerRect.width;

    //Calculated properties
    const calc = {
      id: `ID${Math.floor(Math.random() * 1000000)}`, // id for event handlings,
      chartWidth: attrs.svgWidth,
      chartHeight: attrs.svgHeight,
    };
    attrs.calc = calc;

    // Calculate max node depth (it's needed for layout heights calculation)
    calc.centerX = calc.chartWidth / 2;
    calc.centerY = calc.chartHeight / 2;

    // ******************* BEHAVIORS  **********************
    if (attrs.firstDraw) {
      const behaviors = {
        zoom: null,
      };

      // Get zooming function
      behaviors.zoom = d3
        .zoom()
        .on('zoom', (event, d) => {
          this.zoomed(event, d);
        })
        .scaleExtent(attrs.scaleExtent);
      attrs.zoomBehavior = behaviors.zoom;
    }

    //****************** ROOT node work ************************

    attrs.flexTreeLayout = flextree({
      nodeSize: (node) => {
        const width = attrs.nodeWidth(node);
        const height = attrs.nodeHeight(node);
        const siblingsMargin = attrs.siblingsMargin(node);
        const childrenMargin = attrs.childrenMargin(node);
        return attrs.layoutBindings[attrs.layout].nodeFlexSize({
          state: attrs,
          node: node,
          width,
          height,
          siblingsMargin,
          childrenMargin,
        });
      },
    }).spacing((nodeA, nodeB) =>
      nodeA.parent == nodeB.parent ? 0 : attrs.neightbourMargin(nodeA, nodeB),
    );

    this.setLayouts({ expandNodesFirst: false });

    // *************************  DRAWING **************************
    //Add svg
    const svg = container
      .patternify({
        tag: 'svg',
        selector: 'svg-chart-container',
      })
      .style('background-color', attrs.backgroundColor)
      .attr('width', attrs.svgWidth)
      .attr('height', attrs.svgHeight)
      .attr('font-family', attrs.defaultFont);
    if (attrs.firstDraw) {
      svg
        .call(attrs.zoomBehavior)
        .on('dblclick.zoom', null)
        .attr('cursor', 'move');
    }

    attrs.svg = svg;

    //Add container g element
    const chart = svg.patternify({
      tag: 'g',
      selector: 'chart',
    });

    // Add one more container g element, for better positioning controls
    attrs.centerG = chart.patternify({
      tag: 'g',
      selector: 'center-group',
    });

    attrs.linksWrapper = attrs.centerG.patternify({
      tag: 'g',
      selector: 'links-wrapper',
    });

    attrs.nodesWrapper = attrs.centerG.patternify({
      tag: 'g',
      selector: 'nodes-wrapper',
    });

    attrs.connectionsWrapper = attrs.centerG.patternify({
      tag: 'g',
      selector: 'connections-wrapper',
    });

    attrs.defsWrapper = svg.patternify({
      tag: 'g',
      selector: 'defs-wrapper',
    });

    if (attrs.firstDraw) {
      attrs.centerG.attr('transform', () => {
        return attrs.layoutBindings[attrs.layout].centerTransform({
          centerX: calc.centerX,
          centerY: calc.centerY,
          scale: attrs.lastTransform.k,
          rootMargin: attrs.rootMargin,
          root: attrs.root,
          chartHeight: calc.chartHeight,
          chartWidth: calc.chartWidth,
        });
      });
    }

    attrs.chart = chart;

    // Display tree contenrs
    this.update(attrs.root);

    //#########################################  UTIL FUNCS ##################################
    // This function restyles foreign object elements ()

    d3.select(window).on(`resize.${attrs.id}`, () => {
      const containerRect = d3
        .select(attrs.container)
        .node()
        .getBoundingClientRect();
      attrs.svg.attr('width', containerRect.width);
    });

    if (attrs.firstDraw) {
      attrs.firstDraw = false;
    }

    return this;
  }

  // This function can be invoked via chart.addNode API, and it adds node in tree at runtime
  addNode(obj) {
    const attrs = this.getChartState();
    const nodeFound = attrs.allNodes.filter(
      ({ data }) => attrs.nodeId(data) === attrs.nodeId(obj),
    )[0];
    const parentFound = attrs.allNodes.filter(
      ({ data }) => attrs.nodeId(data) === attrs.parentNodeId(obj),
    )[0];
    if (nodeFound) {
      return this;
    }
    if (!parentFound) {
      return this;
    }
    if (obj._centered && !obj._expanded) obj._expanded = true;
    attrs.data.push(obj);

    // Update state of nodes and redraw graph
    this.updateNodesState();

    return this;
  }

  addAyncNode(obj) {
    const attrs = this.getChartState();
    const nodeFound = attrs.allNodes.filter(
      ({ data }) => attrs.nodeId(data) === attrs.nodeId(obj),
    )[0];
    const parentFound = attrs.allNodes.filter(
      ({ data }) => attrs.nodeId(data) === attrs.parentNodeId(obj),
    )[0];
    if (nodeFound) {
      return this;
    }
    if (!parentFound) {
      return this;
    }
    obj._expanded = true;
    attrs.data.push(obj);

    // Update state of nodes and redraw graph
    this.updateNodesState();

    return this;
  }

  // This function can be invoked via chart.removeNode API, and it removes node from tree at runtime
  removeNode(nodeId) {
    const attrs = this.getChartState();
    const node = attrs.allNodes.filter(
      ({ data }) => attrs.nodeId(data) == nodeId,
    )[0];
    if (!node) {
      return this;
    }

    // Remove all node childs
    // Retrieve all children nodes ids (including current node itself)
    node.descendants().forEach((d) => (d.data._filteredOut = true));

    const descendants = this.getNodeChildren(node, [], attrs.nodeId);
    descendants.forEach((d) => (d._filtered = true));

    // Filter out retrieved nodes and reassign data
    attrs.data = attrs.data.filter((d) => !d._filtered);

    const updateNodesState = this.updateNodesState.bind(this);
    // Update state of nodes and redraw graph
    updateNodesState();

    return this;
  }

  groupBy(array, accessor, aggegator) {
    const grouped = {};
    array.forEach((item) => {
      const key = accessor(item);
      if (!grouped[key]) {
        grouped[key] = [];
      }
      grouped[key].push(item);
    });

    Object.keys(grouped).forEach((key) => {
      grouped[key] = aggegator(grouped[key]);
    });
    return Object.entries(grouped);
  }
  calculateCompactFlexDimensions(root) {
    const attrs = this.getChartState();
    root.eachBefore((node) => {
      node.firstCompact = null;
      node.compactEven = null;
      node.flexCompactDim = null;
      node.firstCompactNode = null;
    });
    root.eachBefore((node) => {
      if (node.children && node.children.length > 1) {
        const compactChildren = node.children.filter((d) => !d.children);
        if (compactChildren.length < 2) return;
        compactChildren.forEach((child, i) => {
          if (!i) child.firstCompact = true;
          if (i % 2) child.compactEven = false;
          else child.compactEven = true;
          child.row = Math.floor(i / 2);
        });
        const evenMaxColumnDimension = d3.max(
          compactChildren.filter((d) => d.compactEven),
          attrs.layoutBindings[attrs.layout].compactDimension.sizeColumn,
        );
        const oddMaxColumnDimension = d3.max(
          compactChildren.filter((d) => !d.compactEven),
          attrs.layoutBindings[attrs.layout].compactDimension.sizeColumn,
        );
        const columnSize =
          Math.max(evenMaxColumnDimension, oddMaxColumnDimension) * 2;
        const rowsMapNew = this.groupBy(
          compactChildren,
          (d) => d.row,
          (reducedGroup) =>
            d3.max(
              reducedGroup,
              (d) =>
                attrs.layoutBindings[attrs.layout].compactDimension.sizeRow(d) +
                attrs.compactMarginBetween(d),
            ),
        );
        const rowSize = d3.sum(rowsMapNew.map((v) => v[1]));
        compactChildren.forEach((node) => {
          node.firstCompactNode = compactChildren[0];
          if (node.firstCompact) {
            node.flexCompactDim = [
              columnSize + attrs.compactMarginPair(node),
              rowSize - attrs.compactMarginBetween(node),
            ];
          } else {
            node.flexCompactDim = [0, 0];
          }
        });
        node.flexCompactDim = null;
      }
    });
  }

  calculateCompactFlexPositions(root) {
    const attrs = this.getChartState();
    root.eachBefore((node) => {
      if (node.children) {
        const compactChildren = node.children.filter((d) => d.flexCompactDim);
        const fch = compactChildren[0];
        if (!fch) return;
        compactChildren.forEach((child, i, arr) => {
          if (i == 0) fch.x -= fch.flexCompactDim[0] / 2;
          if (i & ((i % 2) - 1))
            child.x =
              fch.x +
              fch.flexCompactDim[0] * 0.25 -
              attrs.compactMarginPair(child) / 4;
          else if (i)
            child.x =
              fch.x +
              fch.flexCompactDim[0] * 0.75 +
              attrs.compactMarginPair(child) / 4;
        });
        const centerX = fch.x + fch.flexCompactDim[0] * 0.5;
        fch.x =
          fch.x +
          fch.flexCompactDim[0] * 0.25 -
          attrs.compactMarginPair(fch) / 4;
        const offsetX = node.x - centerX;
        if (Math.abs(offsetX) < 10) {
          compactChildren.forEach((d) => (d.x += offsetX));
        }

        const rowsMapNew = this.groupBy(
          compactChildren,
          (d) => d.row,
          (reducedGroup) =>
            d3.max(reducedGroup, (d) =>
              attrs.layoutBindings[attrs.layout].compactDimension.sizeRow(d),
            ),
        );
        const cumSum = d3.cumsum(
          rowsMapNew.map((d) => d[1] + attrs.compactMarginBetween(d)),
        );
        compactChildren.forEach((node, i) => {
          if (node.row) {
            node.y = fch.y + cumSum[node.row - 1];
          } else {
            node.y = fch.y;
          }
        });
      }
    });
  }

  // This function basically redraws visible graph, based on nodes state
  update({ x0, y0, x = 0, y = 0, width, height }, type) {
    const attrs = this.getChartState();
    const calc = attrs.calc;

    if (attrs.compact) {
      this.calculateCompactFlexDimensions(attrs.root);
    }

    //  Assigns the x and y position for the nodes
    const treeData = attrs.flexTreeLayout(attrs.root);

    // Reassigns the x and y position for the based on the compact layout
    if (attrs.compact) {
      this.calculateCompactFlexPositions(attrs.root);
    }

    const nodes = treeData.descendants();

    // console.table(nodes.map(d => ({ x: d.x, y: d.y, width: d.width, height: d.height, flexCompactDim: d.flexCompactDim + "" })))

    // Get all links
    const links = treeData.descendants().slice(1);
    nodes.forEach(attrs.layoutBindings[attrs.layout].swap);

    // Connections
    const connections = attrs.connections;
    const allNodesMap = {};
    attrs.allNodes.forEach((d) => (allNodesMap[attrs.nodeId(d.data)] = d));

    const visibleNodesMap = {};
    nodes.forEach((d) => (visibleNodesMap[attrs.nodeId(d.data)] = d));

    connections.forEach((connection) => {
      const source = allNodesMap[connection.from];
      const target = allNodesMap[connection.to];
      connection._source = source;
      connection._target = target;
    });
    const visibleConnections = connections.filter(
      (d) => visibleNodesMap[d.from] && visibleNodesMap[d.to],
    );
    const defsString = attrs.defs.bind(this)(attrs, visibleConnections);
    const existingString = attrs.defsWrapper.html();
    if (defsString !== existingString) {
      attrs.defsWrapper.html(defsString);
    }

    // --------------------------  LINKS ----------------------
    // Get links selection
    const linkSelection = attrs.linksWrapper
      .selectAll('path.link')
      .data(links, (d) => attrs.nodeId(d.data));

    // Enter any new links at the parent's previous position.
    const linkEnter = linkSelection
      .enter()
      .insert('path', 'g')
      .attr('class', 'link')
      .attr('d', (d) => {
        const xo = attrs.layoutBindings[attrs.layout].linkJoinX({
          x: x0,
          y: y0,
          width,
          height,
        });
        const yo = attrs.layoutBindings[attrs.layout].linkJoinY({
          x: x0,
          y: y0,
          width,
          height,
        });
        const o = { x: xo, y: yo };
        return attrs.layoutBindings[attrs.layout].diagonal(o, o, o);
      });

    // Get links update selection
    const linkUpdate = linkEnter.merge(linkSelection);

    // Styling links
    linkUpdate.attr('fill', 'none');

    // Allow external modifications
    linkUpdate.each(attrs.linkUpdate);

    // Transition back to the parent element position
    linkUpdate
      .transition()
      .duration(attrs.duration)
      .attr('d', (d) => {
        const n =
          attrs.compact && d.flexCompactDim
            ? {
                x: attrs.layoutBindings[attrs.layout].compactLinkMidX(d, attrs),
                y: attrs.layoutBindings[attrs.layout].compactLinkMidY(d, attrs),
              }
            : {
                x: attrs.layoutBindings[attrs.layout].linkX(d),
                y: attrs.layoutBindings[attrs.layout].linkY(d),
              };

        const p = {
          x: attrs.layoutBindings[attrs.layout].linkParentX(d),
          y: attrs.layoutBindings[attrs.layout].linkParentY(d),
        };

        const m =
          attrs.compact && d.flexCompactDim
            ? {
                x: attrs.layoutBindings[attrs.layout].linkCompactXStart(d),
                y: attrs.layoutBindings[attrs.layout].linkCompactYStart(d),
              }
            : n;
        return attrs.layoutBindings[attrs.layout].diagonal(n, p, m);
      });

    // Remove any  links which is exiting after animation
    const linkExit = linkSelection
      .exit()
      .transition()
      .duration(attrs.duration)
      .attr('d', (d) => {
        const xo = attrs.layoutBindings[attrs.layout].linkJoinX({
          x,
          y,
          width,
          height,
        });
        const yo = attrs.layoutBindings[attrs.layout].linkJoinY({
          x,
          y,
          width,
          height,
        });
        const o = { x: xo, y: yo };
        return attrs.layoutBindings[attrs.layout].diagonal(o, o);
      })
      .remove();

    // --------------------------  CONNECTIONS ----------------------

    const connectionsSel = attrs.connectionsWrapper
      .selectAll('path.connection')
      .data(visibleConnections);

    // Enter any new connections at the parent's previous position.
    const connEnter = connectionsSel
      .enter()
      .insert('path', 'g')
      .attr('class', 'connection')
      .attr('d', (d) => {
        const xo = attrs.layoutBindings[attrs.layout].linkJoinX({
          x: x0,
          y: y0,
          width,
          height,
        });
        const yo = attrs.layoutBindings[attrs.layout].linkJoinY({
          x: x0,
          y: y0,
          width,
          height,
        });
        const o = { x: xo, y: yo };
        return attrs.layoutBindings[attrs.layout].diagonal(o, o);
      });

    // Get connections update selection
    const connUpdate = connEnter.merge(connectionsSel);

    // Styling connections
    connUpdate.attr('fill', 'none');

    // Transition back to the parent element position
    connUpdate
      .transition()
      .duration(attrs.duration)
      .attr('d', (d) => {
        const xs = attrs.layoutBindings[attrs.layout].linkX({
          x: d._source.x,
          y: d._source.y,
          width: d._source.width,
          height: d._source.height,
        });
        const ys = attrs.layoutBindings[attrs.layout].linkY({
          x: d._source.x,
          y: d._source.y,
          width: d._source.width,
          height: d._source.height,
        });
        const xt = attrs.layoutBindings[attrs.layout].linkJoinX({
          x: d._target.x,
          y: d._target.y,
          width: d._target.width,
          height: d._target.height,
        });
        const yt = attrs.layoutBindings[attrs.layout].linkJoinY({
          x: d._target.x,
          y: d._target.y,
          width: d._target.width,
          height: d._target.height,
        });
        return attrs.linkGroupArc({
          source: { x: xs, y: ys },
          target: { x: xt, y: yt },
        });
      });

    // Allow external modifications
    connUpdate.each(attrs.connectionsUpdate);

    // Remove any  links which is exiting after animation
    const connExit = connectionsSel
      .exit()
      .transition()
      .duration(attrs.duration)
      .attr('opacity', 0)
      .remove();

    // --------------------------  NODES ----------------------
    // Get nodes selection
    const nodesSelection = attrs.nodesWrapper
      .selectAll('g.node')
      .data(nodes, ({ data }) => attrs.nodeId(data));

    // Enter any new nodes at the parent's previous position.
    const nodeEnter = nodesSelection
      .enter()
      .append('g')
      .attr('class', 'node')
      .attr('transform', (d) => {
        if (d == attrs.root) return `translate(${x0},${y0})`;
        const xj = attrs.layoutBindings[attrs.layout].nodeJoinX({
          x: x0,
          y: y0,
          width,
          height,
        });
        const yj = attrs.layoutBindings[attrs.layout].nodeJoinY({
          x: x0,
          y: y0,
          width,
          height,
        });
        return `translate(${xj},${yj})`;
      })
      .attr('cursor', 'pointer')
      .on('click', (event, { data }) => {
        if (
          [...event.srcElement.classList].includes('node-button-foreign-object')
        ) {
          return;
        }
        //attrs.onNodeClick(attrs.nodeId(data));
        attrs.onNodeClick(data);
      });

    // Add background rectangle for the nodes
    nodeEnter.patternify({
      tag: 'rect',
      selector: 'node-rect',
      data: (d) => [d],
    });

    // Node update styles
    const nodeUpdate = nodeEnter
      .merge(nodesSelection)
      .style('font', '12px sans-serif');

    // Add foreignObject element inside rectangle
    const fo = nodeUpdate
      .patternify({
        tag: 'foreignObject',
        selector: 'node-foreign-object',
        data: (d) => [d],
      })
      .style('overflow', 'visible');

    // Add foreign object
    fo.patternify({
      tag: 'xhtml:div',
      selector: 'node-foreign-object-div',
      data: (d) => [d],
    });

    this.restyleForeignObjectElements();

    // Add Node button circle's group (expand-collapse button)
    const nodeButtonGroups = nodeEnter
      .patternify({
        tag: 'g',
        selector: 'node-button-g',
        data: (d) => [d],
      })
      .on('click', (event, d) => this.onButtonClick(event, d));

    nodeButtonGroups
      .patternify({
        tag: 'rect',
        selector: 'node-button-rect',
        data: (d) => [d],
      })
      .attr('opacity', 0)
      .attr('pointer-events', 'all')
      .attr('width', 40)
      .attr('height', 40)
      .attr('x', -20)
      .attr('y', -20);

    // Add expand collapse button content
    const nodeFo = nodeButtonGroups
      .patternify({
        tag: 'foreignObject',
        selector: 'node-button-foreign-object',
        data: (d) => [d],
      })
      .attr('width', 40)
      .attr('height', 40)
      .attr('x', -20)
      .attr('y', -20)
      .style('overflow', 'visible')
      .patternify({
        tag: 'xhtml:div',
        selector: 'node-button-div',
        data: (d) => [d],
      })
      .style('pointer-events', 'none')
      .style('display', 'flex')
      .style('width', '100%')
      .style('height', '100%');

    // Transition to the proper position for the node
    nodeUpdate
      .transition()
      .attr('opacity', 0)
      .duration(attrs.duration)
      .attr('transform', ({ x, y, width, height }) => {
        return attrs.layoutBindings[attrs.layout].nodeUpdateTransform({
          x,
          y,
          width,
          height,
        });
      })
      .attr('opacity', 1);

    // Style node rectangles
    nodeUpdate
      .select('.node-rect')
      .attr('width', ({ width }) => width)
      .attr('height', ({ height }) => height)
      .attr('x', ({ width }) => 0)
      .attr('y', ({ height }) => 0)
      .attr('cursor', 'pointer')
      .attr('rx', 3)
      .attr('fill', attrs.nodeDefaultBackground);

    // Move node button group to the desired position
    nodeUpdate
      .select('.node-button-g')
      .attr('transform', ({ data, width, height }) => {
        const x = attrs.layoutBindings[attrs.layout].buttonX({ width, height });
        const y = attrs.layoutBindings[attrs.layout].buttonY({ width, height });
        return `translate(${x},${y})`;
      })
      .attr('display', ({ data }) => {
        return data._directSubordinates > 0 ? null : 'none';
      })
      .attr('opacity', ({ children, _children }) => {
        if (children || _children) {
          return 1;
        }
        return 0;
      });

    // Restyle node button circle
    nodeUpdate
      .select('.node-button-foreign-object .node-button-div')
      .html((node) => {
        return attrs.buttonContent({ node, state: attrs });
      });

    // Restyle button texts
    nodeUpdate
      .select('.node-button-text')
      .attr('text-anchor', 'middle')
      .attr('alignment-baseline', 'middle')
      .attr('fill', attrs.defaultTextFill)
      .attr('font-size', ({ children }) => {
        if (children) return 40;
        return 26;
      })
      .text(({ children }) => {
        if (children) return '-';
        return '+';
      })
      .attr('y', this.isEdge() ? 10 : 0);

    nodeUpdate.each(attrs.nodeUpdate);

    // Remove any exiting nodes after transition
    const nodeExitTransition = nodesSelection
      .exit()
      .attr('opacity', 1)
      .transition()
      .duration(attrs.duration)
      .attr('transform', (d) => {
        const ex = attrs.layoutBindings[attrs.layout].nodeJoinX({
          x,
          y,
          width,
          height,
        });
        const ey = attrs.layoutBindings[attrs.layout].nodeJoinY({
          x,
          y,
          width,
          height,
        });
        return `translate(${ex},${ey})`;
      })
      .on('end', function () {
        d3.select(this).remove();
      })
      .attr('opacity', 0);

    // Store the old positions for transition.
    nodes.forEach((d) => {
      d.x0 = d.x;
      d.y0 = d.y;
    });

    // CHECK FOR CENTERING

    // const centeredNode = attrs.allNodes.filter(d=> d.data._centered === null)[0];
    const centeredNode = attrs.allNodes.filter((d) => d.data._centered)[0];
    if (centeredNode) {
      const centeredNodes = centeredNode.data._centeredWithDescendants
        ? centeredNode.descendants().filter((d, i) => i < 7)
        : [centeredNode];
      centeredNode.data._centeredWithDescendants = null;
      centeredNode.data._centered = null;
      // if (type == 'buttonClick') {
      //   this.fit({
      //     animate: true,
      //     scale: false,
      //     nodes: centeredNodes,
      //     transform: false,
      //   });
      // } else {
      // this.fit();
      // }
      this.fit({
        animate: true,
        scale: false,
        nodes: centeredNodes,
        transform: false,
      });
    }
  }

  // This function detects whether current browser is edge
  isEdge() {
    return window.navigator.userAgent.includes('Edge');
  }

  // Generate horizontal diagonal - play with it here - https://observablehq.com/@bumbeishvili/curved-edges-horizontal-d3-v3-v4-v5-v6
  hdiagonal(s, t, m) {
    // Define source and target x,y coordinates
    const x = s.x;
    const y = s.y;
    const ex = t.x;
    const ey = t.y;

    let mx = (m && m.x) || x;
    let my = (m && m.y) || y;

    // Values in case of top reversed and left reversed diagonals
    let xrvs = ex - x < 0 ? -1 : 1;
    let yrvs = ey - y < 0 ? -1 : 1;

    // Define preferred curve radius
    let rdef = 35;

    // Reduce curve radius, if source-target x space is smaller
    let r = Math.abs(ex - x) / 2 < rdef ? Math.abs(ex - x) / 2 : rdef;

    // Further reduce curve radius, is y space is more small
    r = Math.abs(ey - y) / 2 < r ? Math.abs(ey - y) / 2 : r;

    // Defin width and height of link, excluding radius
    let h = Math.abs(ey - y) / 2 - r;
    let w = Math.abs(ex - x) / 2 - r;

    // Build and return custom arc command
    return `
                  M ${mx} ${my}
                  L ${mx} ${y}
                  L ${x} ${y}
                  L ${x + w * xrvs} ${y}
                  C ${x + w * xrvs + r * xrvs} ${y}
                    ${x + w * xrvs + r * xrvs} ${y}
                    ${x + w * xrvs + r * xrvs} ${y + r * yrvs}
                  L ${x + w * xrvs + r * xrvs} ${ey - r * yrvs}
                  C ${x + w * xrvs + r * xrvs}  ${ey}
                    ${x + w * xrvs + r * xrvs}  ${ey}
                    ${ex - w * xrvs}  ${ey}
                  L ${ex} ${ey}
       `;
  }

  // Generate custom diagonal - play with it here - https://observablehq.com/@bumbeishvili/curved-edges
  diagonal(s, t, m) {
    const x = s.x;
    const y = s.y;
    const ex = t.x;
    const ey = t.y;

    let mx = (m && m.x) || x;
    let my = (m && m.y) || y;

    let xrvs = ex - x < 0 ? -1 : 1;
    let yrvs = ey - y < 0 ? -1 : 1;

    let rdef = 35;
    let r = Math.abs(ex - x) / 2 < rdef ? Math.abs(ex - x) / 2 : rdef;

    r = Math.abs(ey - y) / 2 < r ? Math.abs(ey - y) / 2 : r;

    let h = Math.abs(ey - y) / 2 - r;
    let w = Math.abs(ex - x) - r * 2;
    //w=0;
    const path = `
                  M ${mx} ${my}
                  L ${x} ${my}
                  L ${x} ${y}
                  L ${x} ${y + h * yrvs}
                  C  ${x} ${y + h * yrvs + r * yrvs} ${x} ${
      y + h * yrvs + r * yrvs
    } ${x + r * xrvs} ${y + h * yrvs + r * yrvs}
                  L ${x + w * xrvs + r * xrvs} ${y + h * yrvs + r * yrvs}
                  C  ${ex}  ${y + h * yrvs + r * yrvs} ${ex}  ${
      y + h * yrvs + r * yrvs
    } ${ex} ${ey - h * yrvs}
                  L ${ex} ${ey}
       `;
    return path;
  }

  restyleForeignObjectElements() {
    const attrs = this.getChartState();

    attrs.svg
      .selectAll('.node-foreign-object')
      .attr('width', ({ width }) => width)
      .attr('height', ({ height }) => height)
      .attr('x', ({ width }) => 0)
      .attr('y', ({ height }) => 0);
    attrs.svg
      .selectAll('.node-foreign-object-div')
      .style('width', ({ width }) => `${width}px`)
      .style('height', ({ height }) => `${height}px`)
      .html(function (d, i, arr) {
        return d.id ? attrs.nodeContent.bind(this)(d, i, arr, attrs) : '';
      });
  }

  // Toggle children on click.
  onButtonClick(event, d) {
    const attrs = this.getChartState();
    if (attrs.setActiveNodeCentered) {
      d.data._centered = true;
      d.data._centeredWithDescendants = true;
    }

    // If childrens are expanded
    if (d.children) {
      //Collapse them
      d._children = d.children;
      d.children = null;

      // Set descendants expanded property to false
      this.setExpansionFlagToChildren(d, false);
    } else {
      // Expand children
      d.children = d._children;
      d._children = null;

      // Set each children as expanded
      if (d.children) {
        d.children.forEach(({ data }) => (data._expanded = true));
      }
    }

    // Redraw Graph
    this.update(d, 'buttonClick');
  }

  // This function changes `expanded` property to descendants
  setExpansionFlagToChildren({ data, children, _children }, flag) {
    // Set flag to the current property
    data._expanded = flag;

    // Loop over and recursively update expanded children's descendants
    if (children) {
      children.forEach((d) => {
        this.setExpansionFlagToChildren(d, flag);
      });
    }

    // Loop over and recursively update collapsed children's descendants
    if (_children) {
      _children.forEach((d) => {
        this.setExpansionFlagToChildren(d, flag);
      });
    }
  }

  // Method which only expands nodes, which have property set "expanded=true"
  expandSomeNodes(d) {
    // If node has expanded property set
    if (d.data._expanded) {
      // Retrieve node's parent
      let parent = d.parent;

      // While we can go up
      while (parent) {
        // Expand all current parent's children
        if (parent._children) {
          parent.children = parent._children;
        }

        // Replace current parent holding object
        parent = parent.parent;
      }
    }

    // Recursivelly do the same for collapsed nodes
    if (d._children) {
      d._children.forEach((ch) => this.expandSomeNodes(ch));
    }

    // Recursivelly do the same for expanded nodes
    if (d.children) {
      d.children.forEach((ch) => this.expandSomeNodes(ch));
    }
  }

  // This function updates nodes state and redraws graph, usually after data change
  updateNodesState() {
    const attrs = this.getChartState();

    this.setLayouts({ expandNodesFirst: true });

    // Redraw Graphs
    this.update(attrs.root);
  }

  setLayouts({ expandNodesFirst = true }) {
    const attrs = this.getChartState();
    // Store new root by converting flat data to hierarchy
    attrs.root = d3
      .stratify()
      .id((d) => attrs.nodeId(d))
      .parentId((d) => attrs.parentNodeId(d))(attrs.data);

    attrs.root.each((node, i, arr) => {
      let width = attrs.nodeWidth(node);
      let height = attrs.nodeHeight(node);
      Object.assign(node, { width, height });
    });

    // Store positions, where children appear during their enter animation
    attrs.root.x0 = 0;
    attrs.root.y0 = 0;
    attrs.allNodes = attrs.root.descendants();

    // Store direct and total descendants count
    attrs.allNodes.forEach((d) => {
      Object.assign(d.data, {
        _directSubordinates: d.children ? d.children.length : 0,
        _totalSubordinates: d.descendants().length - 1,
      });
    });

    if (attrs.root.children) {
      if (expandNodesFirst) {
        // Expand all nodes first
        attrs.root.children.forEach(this.expand);
        attrs.root.children.forEach((item) => {
          item?.children?.forEach(this.expand);
        });
      }
      // Then collapse them all
      attrs.root.children.forEach((d) => this.collapse(d));

      // Collapse root if level is 0
      if (attrs.expandLevel == 0) {
        attrs.root._children = attrs.root.children;
        attrs.root.children = null;
      }

      // Then only expand nodes, which have expanded proprty set to true
      [attrs.root].forEach((ch) => this.expandSomeNodes(ch));
    }
  }

  // Function which collapses passed node and it's descendants
  collapse(d) {
    if (d.children) {
      d._children = d.children;
      d._children.forEach((ch) => this.collapse(ch));
      d.children = null;
    }
  }

  // Function which expands passed node and it's descendants
  expand(d) {
    if (d._children) {
      d.children = d._children;
      d.children.forEach((ch) => this.expand(ch));
      d._children = null;
    }
  }

  // Zoom handler function
  zoomed(event, d) {
    const attrs = this.getChartState();
    const chart = attrs.chart;

    // Get d3 event's transform object
    const transform = event.transform;

    // Store it
    attrs.lastTransform = transform;

    // Reposition and rescale chart accordingly
    chart.attr('transform', transform);

    // Apply new styles to the foreign object element
    if (this.isEdge()) {
      this.restyleForeignObjectElements();
    }
  }

  zoomTreeBounds({ x0, x1, y0, y1, params = { animate: true, scale: true } }) {
    const {
      centerG,
      svgWidth: w,
      svgHeight: h,
      svg,
      zoomBehavior,
      duration,
      lastTransform,
    } = this.getChartState();
    let scaleVal = Math.min(8, 0.9 / Math.max((x1 - x0) / w, (y1 - y0) / h));
    let identity = d3.zoomIdentity.translate(w / 2, h / 2);
    identity = identity.scale(params.scale ? scaleVal : lastTransform.k);

    identity = identity.translate(-(x0 + x1) / 2, -(y0 + y1) / 2);
    // Transition zoom wrapper component into specified bounds
    svg
      .transition()
      .duration(params.animate ? duration : 0)
      .call(zoomBehavior.transform, identity);
    centerG
      .transition()
      .duration(params.animate ? duration : 0)
      .attr('transform', 'translate(0,0)');
  }

  fit({ animate = true, nodes, scale = true, transform = true } = {}) {
    const attrs = this.getChartState();
    const { root } = attrs;
    let descendants = nodes ? nodes : root.descendants();
    const minX = d3.min(
      descendants,
      (d) => d.x + attrs.layoutBindings[attrs.layout].nodeLeftX(d),
    );
    const maxX = d3.max(
      descendants,
      (d) => d.x + attrs.layoutBindings[attrs.layout].nodeRightX(d),
    );
    const minY = d3.min(
      descendants,
      (d) => d.y + attrs.layoutBindings[attrs.layout].nodeTopY(d),
    );
    const maxY = d3.max(
      descendants,
      (d) => d.y + attrs.layoutBindings[attrs.layout].nodeBottomY(d),
    );
    this.zoomTreeBounds({
      params: { animate: animate, scale },
      x0: transform == 'export' ? minX - 700 : minX - 50,
      x1: transform == 'export' ? maxX + 0 : maxX - 50,
      y0: transform == false ? minY - 250 : minY - 0,
      y1: transform == false ? maxY + 350 : maxY + 250,
    });
    return this;
  }

  // This function can be invoked via chart.setExpanded API, it expands or collapses particular node
  setExpanded(id, expandedFlag = true) {
    const attrs = this.getChartState();
    // Retrieve node by node Id
    const node = attrs.allNodes.filter(
      ({ data }) => attrs.nodeId(data) == id,
    )[0];

    if (!node) {
      return this;
    }
    node.data._expanded = expandedFlag;
    return this;
  }

  setCentered(nodeId) {
    const attrs = this.getChartState();
    // this.setExpanded(nodeId)
    const node = attrs.allNodes?.filter(
      (d) => attrs.nodeId(d.data) === nodeId,
    )[0];
    if (!node) {
      return this;
    }
    node.data._centered = true;
    node.data._expanded = true;
    return this;
  }

  setHighlighted(nodeId) {
    const attrs = this.getChartState();
    const node = attrs.allNodes.filter(
      (d) => attrs.nodeId(d.data) === nodeId,
    )[0];
    if (!node) {
      return this;
    }
    node.data._highlighted = true;
    node.data._expanded = true;
    node.data._centered = true;
    return this;
  }

  setUpToTheRootHighlighted(nodeId) {
    const attrs = this.getChartState();
    const node = attrs.allNodes.filter(
      (d) => attrs.nodeId(d.data) === nodeId,
    )[0];
    if (!node) {
      return this;
    }
    node.data._upToTheRootHighlighted = true;
    node.data._expanded = true;
    node.ancestors().forEach((d) => (d.data._upToTheRootHighlighted = true));
    return this;
  }

  clearHighlighting() {
    const attrs = this.getChartState();
    attrs.allNodes.forEach((d) => {
      d.data._highlighted = false;
      d.data._upToTheRootHighlighted = false;
    });
    this.update(attrs.root);
  }

  // It can take selector which would go fullscreen
  fullscreen(elem) {
    const attrs = this.getChartState();
    const el = d3.select(elem || attrs.container).node();

    d3.select(document).on('fullscreenchange.' + attrs.id, function (d) {
      const fsElement =
        document.fullscreenElement ||
        document.mozFullscreenElement ||
        document.webkitFullscreenElement;
      if (fsElement == el) {
        setTimeout((d) => {
          attrs.svg.attr('height', window.innerHeight - 40);
        }, 500);
      } else {
        attrs.svg.attr('height', attrs.svgHeight);
      }
    });

    if (el.requestFullscreen) {
      el.requestFullscreen();
    } else if (el.mozRequestFullScreen) {
      el.mozRequestFullScreen();
    } else if (el.webkitRequestFullscreen) {
      el.webkitRequestFullscreen();
    } else if (el.msRequestFullscreen) {
      el.msRequestFullscreen();
    }
  }

  // Zoom in exposed method
  zoomIn() {
    const { svg, zoomBehavior } = this.getChartState();
    svg.transition().call(zoomBehavior.scaleBy, 1.3);
  }

  // Zoom out exposed method
  zoomOut() {
    const { svg, zoomBehavior } = this.getChartState();
    svg.transition().call(zoomBehavior.scaleBy, 0.78);
  }

  toDataURL(url, callback) {
    var xhr = new XMLHttpRequest();
    xhr.onload = function () {
      var reader = new FileReader();
      reader.onloadend = function () {
        callback(reader.result);
      };
      reader.readAsDataURL(xhr.response);
    };
    xhr.open('GET', url);
    xhr.responseType = 'blob';
    xhr.send();
  }

  exportImg({ full = false, scale = 12, onLoad = (d) => d, save = true } = {}) {
    const that = this;
    const attrs = this.getChartState();
    const { svg: svgImg, root } = attrs;
    let count = 0;
    const selection = svgImg.selectAll('img');
    let total = selection.size();

    const exportImage = () => {
      const transform = JSON.parse(JSON.stringify(that.lastTransform()));
      const duration = that.duration();
      if (full) {
        that.fit({ transform: 'export' });
      }
      const { svg } = that.getChartState();

      setTimeout(
        (d) => {
          that.downloadImage({
            node: svg.node(),
            scale,
            isSvg: false,
            onAlreadySerialized: (d) => {
              that.update(root);
            },
            onLoad: onLoad,
            save,
          });
        },
        full ? duration + 10 : 0,
      );
    };

    if (total > 0) {
      selection.each(function () {
        that.toDataURL(this.src, (dataUrl) => {
          this.src = dataUrl;
          if (++count == total) {
            exportImage();
          }
        });
      });
    } else {
      exportImage();
    }
  }

  exportSvg() {
    const { svg } = this.getChartState();
    this.downloadImage({ node: svg.node(), scale: 3, isSvg: true });
    return this;
  }

  expandAll() {
    const { allNodes, root } = this.getChartState();
    allNodes.forEach((d) => (d.data._expanded = true));
    this.render();
    return this;
  }

  expandExpandNodes() {
    const { allNodes, root } = this.getChartState();
    // allNodes.forEach(d => d.data._expanded = true);
    allNodes.forEach(
      (d) => (d.data._expanded = d.data.expand === '1' ? true : false),
    );
    this.render();
    return this;
  }

  collapseAll() {
    const { allNodes, root } = this.getChartState();
    allNodes.forEach((d) => (d.data._expanded = false));
    this.expandLevel(0);
    this.render();
    return this;
  }

  downloadImage({
    node,
    scale = 2,
    isSvg = false,
    save = true,
    onAlreadySerialized = (d) => {},
    onLoad = (d) => {},
  }) {
    // Retrieve svg node
    const svgNode = node;

    if (isSvg) {
      let source = serializeString(svgNode);
      //add xml declaration
      source = '<?xml version="1.0" standalone="no"?>\r\n' + source;
      //convert svg source to URI data scheme.
      var url =
        'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(source);
      saveAs(url, 'graph.svg');
      onAlreadySerialized();
      return;
    }
    // Get image quality index (basically,  index you can zoom in)
    const quality = scale;
    // Create image
    const image = document.createElement('img');
    image.onload = function () {
      // Create image canvas
      const canvas = document.createElement('canvas');
      // Set width and height based on SVG node
      const rect = svgNode.getBoundingClientRect();
      canvas.width = rect.width * quality;
      canvas.height = rect.height * quality;
      // Draw background
      const context = canvas.getContext('2d');
      context.fillStyle = '#FAFAFA';
      context.fillRect(0, 0, rect.width * quality, rect.height * quality);
      context.drawImage(
        image,
        0,
        0,
        rect.width * quality,
        rect.height * quality,
      );
      // Set some image metadata
      let dt = canvas.toDataURL('image/png');
      if (onLoad) {
        onLoad(dt);
      }
      if (save) {
        // Invoke saving function
        saveAs(dt, 'graph.png');
      }
    };

    var url =
      'data:image/svg+xml; charset=utf8, ' +
      encodeURIComponent(serializeString(svgNode));

    onAlreadySerialized();

    image.src = url; // URL.createObjectURL(blob);
    // This function invokes save window
    function saveAs(uri, filename) {
      // create link
      var link = document.createElement('a');
      if (typeof link.download === 'string') {
        document.body.appendChild(link); // Firefox requires the link to be in the body
        link.download = filename;
        link.href = uri;
        link.click();
        document.body.removeChild(link); // remove the link when done
      } else {
        location.replace(uri);
      }
    }
    // This function serializes SVG and sets all necessary attributes
    function serializeString(svg) {
      const xmlns = 'http://www.w3.org/2000/xmlns/';
      const xlinkns = 'http://www.w3.org/1999/xlink';
      const svgns = 'http://www.w3.org/2000/svg';
      svg = svg.cloneNode(true);
      const fragment = window.location.href + '#';
      const walker = document.createTreeWalker(
        svg,
        NodeFilter.SHOW_ELEMENT,
        null,
        false,
      );
      while (walker.nextNode()) {
        for (const attr of walker.currentNode.attributes) {
          if (attr.value.includes(fragment)) {
            attr.value = attr.value.replace(fragment, '#');
          }
        }
      }
      svg.setAttributeNS(xmlns, 'xmlns', svgns);
      svg.setAttributeNS(xmlns, 'xmlns:xlink', xlinkns);
      const serializer = new XMLSerializer();
      const string = serializer.serializeToString(svg);
      return string;
    }
  }

  // Calculate what size text will take
  getTextWidth(
    text,
    { fontSize = 14, fontWeight = 400, defaultFont = 'Helvetice', ctx } = {},
  ) {
    ctx.font = `${fontWeight || ''} ${fontSize}px ${defaultFont} `;
    const measurement = ctx.measureText(text);
    return measurement.width;
  }
}
