angulard3.jsd3tree

Angular D3 tree does not collapse back into its parent


We were able to use this example for Angular here(https://bl.ocks.org/d3noob/1a96af738c89b88723eb63456beb6510) and achieve the collapsible tree diagram. But it's not collapse back into its parent or our click action is not working properly.

Here is my code: https://stackblitz.com/edit/angular-ivy-acd2yd?file=src/app/app.component.ts


Solution

  • Transform a code from JS to typeScript it's not only Copy+Paste. We need go slower.

    First, in typescript we use let or const to have a block-scope instead of var. "var" create a variable global to all the application

    After, we needn't put all the code in ngOnInit. We should separate in functions all the code under ngOnInit. We can get off variables and declare outside the ngOnInit

      treeData:any={...}
      margin = { top: 0, right: 30, bottom: 0, left: 30 };
      duration = 750;
    
      width: number;
      height: number;
      svg: any;
      root: any;
    
      i = 0;
      treemap: any;
    

    Also we need get off the functions, so we has the functions

      update(source:any){
          ...
      }
      collapse(d: any) {
        if (d.children) {
          d._children = d.children;
          d._children.forEach((d:any)=>this.collapse(d));
          d.children = null;
        }
      }
    
      click(d: any) {
        if (d.children) {
          d._children = d.children;
          d.children = null;
        } else {
          d.children = d._children;
          d._children = null;
        }
        this.update(d);
      }
    
      diagonal(s: any, d: any) {
        const path = `M ${s.y} ${s.x}
                C ${(s.y + d.y) / 2} ${s.x},
                  ${(s.y + d.y) / 2} ${d.x},
                  ${d.y} ${d.x}`;
    
        return path;
      }
    

    And transfor all the functions use the flat arrow sintax, so

        //in stead of use 
        .attr('transform', function (d: any) {
          return 'translate(' + source.y0 + ',' + source.x0 + ')';
        })
    
        //we use
        .attr('transform', (d: any) => {
          return 'translate(' + source.y0 + ',' + source.x0 + ')';
        })
    

    And use this. to make reference to the variables of the component.

    After all of this, Out ngOnInit becomes like

    ngOnInit(){
        this.svg = d3
          .select('#d3noob')
          .append('svg')
          .attr('viewBox','0 0 900 500')
          .append('g')
          .attr(
            'transform',
            'translate(' + (this.margin.left+inc) + ',' + this.margin.top + ')'
          );
    
        // declares a tree layout and assigns the size
        this.treemap = d3.tree().size([this.height, this.width]);
    
        // Assigns parent, children, height, depth
        this.root = d3.hierarchy(this.treeData, (d: any) => {
          return d.children;
        });
    
        this.root.x0 = this.height / 2;
        this.root.y0 = 0;
        // Collapse after the second level
        this.root.children.forEach((d:any) => {
          this.collapse(d);
        });
    
        this.update(this.root);
    }
    

    And the function update

      update(source: any) {
        // Assigns the x and y position for the nodes
        const treeData = this.treemap(this.root);
    
        // Compute the new tree layout.
        const nodes = treeData.descendants();
        const links = treeData.descendants().slice(1);
    
        // Normalize for fixed-depth.
        nodes.forEach((d: any) => {
          d.y = d.depth * 180;
        });
    
        // ****************** Nodes section ***************************
    
        // Update the nodes...
        const node = this.svg.selectAll('g.node').data(nodes, (d: any) => {
          return d.id || (d.id = ++this.i);
        });
    
        // Enter any new modes at the parent's previous position.
        const nodeEnter = node
          .enter()
          .append('g')
          .attr('class', 'node')
          .attr('transform', (d: any) => {
            return 'translate(' + source.y0 + ',' + source.x0 + ')';
          })
          .on('click', (_, d) => this.click(d));
    
        // Add Circle for the nodes
        nodeEnter
          .append('circle')
          .attr('class', (d:any)=> d._children?'node fill':'node')
          .attr('r', 1e-6)
        // Add labels for the nodes
        nodeEnter
          .append('text')
          .attr('dy', '.35em')
          
          .attr('x', (d) => {
            return d.children || d._children ? -13 : 13;
          })
          .attr('text-anchor', (d: any) => {
            return d.children || d._children ? 'end' : 'start';
          })
          .text((d) => {
            return d.data.name;
          });
        // UPDATE
        const nodeUpdate = nodeEnter.merge(node);
    
        // Transition to the proper position for the node
        nodeUpdate
          .transition()
          .duration(this.duration)
          .attr('transform', (d: any) => {
            return 'translate(' + d.y + ',' + d.x + ')';
          });
    
        // Update the node attributes and style
        nodeUpdate
          .select('circle.node')
          .attr('r', 10)
          .attr('class', (d:any)=> d._children?'node fill':'node')
          .attr('cursor', 'pointer');
    
        // Remove any exiting nodes
        const nodeExit = node
          .exit()
          .transition()
          .duration(this.duration)
          .attr('transform', (d: any) => {
            return 'translate(' + source.y + ',' + source.x + ')';
          })
          .remove();
    
        // On exit reduce the node circles size to 0
        nodeExit.select('circle').attr('r', 1e-6);
    
        // On exit reduce the opacity of text labels
        nodeExit.select('text').style('fill-opacity', 1e-6);
    
        // ****************** links section ***************************
    
        // Update the links...
        const link = this.svg.selectAll('path.link').data(links, (d: any) => {
          return d.id;
        });
    
        // Enter any new links at the parent's previous position.
        const linkEnter = link
          .enter()
          .insert('path', 'g')
          .attr('class', 'link')
          .attr('d', (d: any) => {
            const o = { x: source.x0, y: source.y0 };
            return this.diagonal(o, o);
          });
    
        // UPDATE
        const linkUpdate = linkEnter.merge(link);
    
        // Transition back to the parent element position
        linkUpdate
          .transition()
          .duration(this.duration)
          .attr('d', (d: any) => {
            return this.diagonal(d, d.parent);
          });
    
        // Remove any exiting links
        const linkExit = link
          .exit()
          .transition()
          .duration(this.duration)
          .attr('d', (d: any) => {
            const o = { x: source.x, y: source.y };
            return this.diagonal(o, o);
          })
          .remove();
    
        // Store the old positions for transition.
        nodes.forEach((d: any) => {
          d.x0 = d.x;
          d.y0 = d.y;
        });
      }
    

    See that there're a minor changes because I choose use viewPort to make the svg fill the width of the screen if it's less than 960px and control the class of the "dots" using .css (In the code it was "hardcode" the "fill of the dots")

    So, before, when we create the .svg we give value to width and height and now I give value to viewBox"

    this.svg = d3
      .select('#d3noob')
      .append('svg')
      .attr('viewBox','0 0 960 500')
      .append('g')
      .attr(
        'transform',
        'translate(' + (this.margin.left+inc) + ',' + this.margin.top + ')'
      );
    

    Finally We create a component instead write the code in the app.component. For this we need some variables was inputs

      @Input()treeData:any={}
    
      @Input()margin = { top: 0, right: 30, bottom: 0, left: 30 };
      @Input()duration = 750;
    

    The last is give credit to the author using a comment

    As I choose the svg was adaptative we need calculate the "margin" to allow the text of the first node was visible. To make this, I create a "visibility:hidden" span with the text of the this node to calculate the "margin". Futhermore, I want that the text was visible, so force the font-size was around 14px creating an observable in the way

      fontSize=fromEvent(window,'resize').pipe(
        startWith(null),
        map(_=>{
          return window.innerWidth>960?'14px':14*960/window.innerWidth+'px'
        }),
    

    The final stackblitz is here (you can compare the code)

    Update Really I don't like so much the result

    In this stackblitz I improve a bit the code. The diferences are that I change the width,height and viewPort using a function

      updateSize() {
        this.width = this.wrapper.nativeElement.getBoundingClientRect().width
        this.svg
          .attr('preserveAspectRatio', 'xMidYMid meet')
          .attr('width', '100%')
          .attr('height', this.height + 'px')
          .attr('viewBox', ''+(-this.margin.left)+' 0 ' + this.width  + ' ' + this.height);
      }
    

    To avoid "crop" I change the "harcode" space between the nodes

    // Normalize for fixed-depth.
    nodes.forEach((d: any) => {
      d.y = (d.depth * (this.width-this.margin.left-this.margin.right))
              / this.maxDepth;
    });
    

    Where this.maxDepth is calculate using a recursive function about treeData

      this.maxDepth = this.depthOfTree(this.treeData);
      depthOfTree(ptr: any, maxdepth: number = 0) {
        if (ptr == null || !ptr.children) return maxdepth;
    
        for (let it of ptr.children)
          maxdepth = Math.max(maxdepth, this.depthOfTree(it));
    
        return maxdepth + 1;
      }
    

    I need also use the "margin" variable that I hardcode like

      margin = { top: 0, right: 130, bottom: 0, left: 80 };
    

    That allow the SVG don't crop the text