1

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

VVD
  • 178
  • 1
  • 9
Anish Manchappillil
  • 697
  • 2
  • 10
  • 19

2 Answers2

1

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

Eliseo
  • 50,109
  • 4
  • 29
  • 67
0

This answer is continues of the another answer. I improve the stackblitz to don't hardcode the "margins". I know I could edit the answer but there're a lot of changes. So first I want to explain a tree work.

When we write

this.treemap = d3.tree().size([100,100]);

This calculate the positions of the nodes (x and y) in the way the "dots" are contains in a rectangle of 100x100px. So we can "scale" in the way

nodes.forEach((d: any) => {
  d.y = d.depth * step+innerMargin;
  d.x=this.height/2+(d.x-50)*this.height/100
});

where "this.height" is the "height" of the svg and step is the disante between two nodes.

So, first defined several inputs that we need: variables we need

  @Input() set treeData(value) {
    this._treeData = value;
    this.maxDepth = this.depthOfTree(this._treeData);
  }

  get treeData() {
    return this._treeData;
  }

  @Input() duration = 750;

  @Input('max-height') set __(value: number) {
    this.maxHeight = value;
  }
  @Input('aspect-ratio') set _(value: number | string) {
    const split = ('' + value).split(':');
    this.factor = +split[1] / +split[0];
  }

See that we store in variables this.factor the "aspect-ratio" and we use a "getter" with threeData to get the "maxDepth"

I want to know the size of the text, so I want to create an array of strings with the text and draw with a style "visiblility:hidden". I want also get the first of the text and the larger text so we use

  labels: string[] = [];
  margin = { right: 100, left: 100 };
  firstLabel: any;
  lastLabel: any;

I write a template like

<span #label *ngFor="let label of labels" class='fade'>
   {{label}}
</span>
<div #wrapper id="tree" [attr.data-size]="size$|async" class="wrapper">
   <svg></svg>
</div>

I want to change the font-size using media-queries, so I go to use ViewEncapsultion.None. This makes that the .css was to all the application, so, for avoid confict, we prexis all the .css with the selector of the component. futhermore. I choose use css variables. This allow we can change the colors of the nodes using this variables.

  d3noob-collapsible-tree .wrapper{
    position:relative;
    max-width:960px;
    margin-left:auto;
    margin-right:auto;
    text-align:center;
  }
  d3noob-collapsible-tree .fade{
    display:inline-block;
    border:1px solid black;
    position:absolute;
    visibility:hidden;
  }
  d3noob-collapsible-tree .node circle {
    stroke-width: var(--circle-stroke-width,1px);
    stroke: var(--circle-stroke,steelblue);;
  }
  d3noob-collapsible-tree .node.fill {
    fill: var(--circle-fill,lightsteelblue);;
  }
  
  d3noob-collapsible-tree .link {
    stroke:var(--stroke-link,#ccc);
    stroke-width: var(--stroke-width-link,1px);
  }
  d3noob-collapsible-tree .node text,d3noob-collapsible-tree .fade {
    font-family: sans-serif;
    font-size: .675em;
  }
  d3noob-collapsible-tree .node circle {
    fill: var(--circle-empty,white);
  }
  
  d3noob-collapsible-tree .link {
    fill: none;
  }
  
  @media (min-width: 400px) {
    d3noob-collapsible-tree .node text,d3noob-collapsible-tree .fade {
      font-size: .75em;
    }
  }
  @media (min-width: 600px) {
    d3noob-collapsible-tree .node text,d3noob-collapsible-tree .fade {
      font-size: .875em;
    }
  }

We can in styles. css use some like

d3noob-collapsible-tree
{
  --stroke-link:#FFC0CB;
  --stroke-width-link:1px;
  --circle-empty:#FFC0CB;
  --circle-fill:#FF69B4;
  --circle-stroke:#C71585;
  --circle-stroke-width:0;
}
d3noob-collapsible-tree .node circle {
  filter: drop-shadow(1px 1px 2px rgba(0,0,0,.15));
}

Well now, we are using ngAfterViewInit to create the tree and get the "firstLabel" (the #label of the "main node") and the "lastLabel" (the label with the larger width)

  @ViewChildren('label') labelsDiv: QueryList<ElementRef>;
  firstLabel: any;
  lastLabel: any;


  ngAfterViewInit(): void {
    this.firstLabel = this.labelsDiv.first.nativeElement;
    this.labelsDiv.forEach((x) => {
      this.lastLabel = !this.lastLabel
        ? x.nativeElement
        : this.lastLabel.getBoundingClientRect().width <
          x.nativeElement.getBoundingClientRect()
        ? x.nativeElement
        : this.lastLabel;
    });
    this.svg = d3.select('#tree').select('svg');
    this.svg.attr('preserveAspectRatio', 'xMidYMid meet').append('g');

    // declares a tree layout and assigns the size
    this.treemap = d3.tree().size([100, 100]);

    // Assigns parent, children, height, depth
    this.root = d3.hierarchy(this.treeData, (d: any) => {
      return d.children;
    });

    this.updateSize();
    setTimeout(() => {
      this.updateSize();
      this.root.children.forEach((d: any) => {
        this.collapse(d);
      });
      this.update(this.root);
    });
  }

The updateSize change the size of the svg taking account of the "margin"

  updateSize() {
    this.margin.left = this.firstLabel.getBoundingClientRect().width + 25;
    this.margin.right = this.lastLabel.getBoundingClientRect().width + 50;
    this.width = this.wrapper.nativeElement.getBoundingClientRect().width;
    if (this.factor)
      this.height =
        this.width * this.factor < this.maxHeight
          ? this.width * this.factor
          : this.maxHeight;
    else this.height = this.maxHeight;

    this.svg
      .attr('preserveAspectRatio', 'xMidYMid meet')
      .attr('width', this.width + 'px')
      .attr('height', this.height + 'px')
      .attr(
        'viewBox',
        '-' + this.margin.left + ' 0 ' + this.width + ' ' + this.height
      );
  }

See that we use the width and height to create the viewBox and the width and height and we use -magin.left in viewPost to "horizontal translate" the node-

The update is only the translate to typescript the functions in JS

  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);

    let step =
      (this.width - this.margin.left - this.margin.right) / this.maxDepth;
    let innerMargin = 0;
    if (step > this.lastLabel.getBoundingClientRect().width + 100) {
      step = this.lastLabel.getBoundingClientRect().width + 100;
      innerMargin =
        (this.width -
          step * this.maxDepth -
          this.margin.left -
          this.margin.right -
          10) /
        2;
    }
    this.root.x0 = this.height / 2;
    this.root.y0 = 0;
    // Normalize for fixed-depth.
    nodes.forEach((d: any) => {
      d.y = d.depth * step + innerMargin;
      d.x = this.height / 2 + ((d.x - 50) * this.height) / 100;
    });
    // ****************** 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('text-rendering', 'optimizeLegibility')
      .attr('dy', '.35em')

      .attr('cursor', (d) => (d.children || d._children ? 'pointer' : 'auto'))
      .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', (d) => (d.children || d._children ? 'pointer' : 'auto'));

    // 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;
    });
  }

Thanks you for reading, the final stackblitz

Eliseo
  • 50,109
  • 4
  • 29
  • 67