File

projects/web-mev/src/app/d3/components/hcl/hcl.component.ts

Description

HCL Component

Used for Hierarchical Clustering (HCL) analysis

Implements

OnChanges

Metadata

changeDetection ChangeDetectionStrategy.Default
selector mev-hcl
styleUrls ./hcl.component.scss
templateUrl ./hcl.component.html

Index

Properties
Methods
Inputs

Constructor

constructor(apiService: AnalysesService, metadataService: MetadataService, dialog: MatDialog)
Parameters :
Name Type Optional
apiService AnalysesService No
metadataService MetadataService No
dialog MatDialog No

Inputs

outputs

Methods

Private createChart
createChart(hierData: any, containerId: string)

Function to create dendrogram

Parameters :
Name Type Optional
hierData any No
containerId string No
Returns : void
generateHCL
generateHCL()

Function to retrieve data for Observation HCL plot

Returns : void
ngOnChanges
ngOnChanges()
Returns : void
onCreateCustomSampleSet
onCreateCustomSampleSet()

Function that is triggered when the user clicks the "Create a custom sample" button

Returns : void
onResize
onResize(event)
Parameters :
Name Optional
event No
Returns : void

Properties

customObservationSets
Type : []
Default value : []
Public dialog
Type : MatDialog
hierObsData
margin
Type : object
Default value : { top: 50, right: 300, bottom: 50, left: 50 }
maxTextLabelLength
Type : number
Default value : 10
obsImageName
Type : string
Default value : 'Hierarchical clustering - Observations'
obsTreeContainerId
Type : string
Default value : '#observationPlot'
outerHeight
Type : number
Default value : 500
selectedSamples
Type : []
Default value : []
svgElement
Type : ElementRef
Decorators :
@ViewChild('treePlot')
tooltipOffsetX
Type : number
Default value : 10
import {
  Component,
  ChangeDetectionStrategy,
  Input,
  OnChanges,
  ElementRef,
  ViewChild
} from '@angular/core';
import { AnalysesService } from '@app/features/analysis/services/analysis.service';
import * as d3 from 'd3';
import d3Tip from 'd3-tip';
import { AddSampleSetComponent } from '../dialogs/add-sample-set/add-sample-set.component';
import { MatDialog } from '@angular/material/dialog';
import { MetadataService } from '@app/core/metadata/metadata.service';
import { CustomSetType } from '@app/_models/metadata';

/**
 * HCL Component
 *
 * Used for Hierarchical Clustering (HCL) analysis
 */
@Component({
  selector: 'mev-hcl',
  templateUrl: './hcl.component.html',
  styleUrls: ['./hcl.component.scss'],
  changeDetection: ChangeDetectionStrategy.Default
})
export class HclComponent implements OnChanges {
  @Input() outputs;
  @ViewChild('treePlot') svgElement: ElementRef;
  hierObsData;

  customObservationSets = [];
  selectedSamples = [];

  /* Chart settings */
  obsTreeContainerId = '#observationPlot'; // chart container id
  obsImageName = 'Hierarchical clustering - Observations'; // file name for downloaded SVG image
  margin = { top: 50, right: 300, bottom: 50, left: 50 }; // chart margins
  outerHeight = 500;
  maxTextLabelLength = 10;
  tooltipOffsetX = 10; // position the tooltip on the right side of the triggering element

  constructor(
    private apiService: AnalysesService,
    private metadataService: MetadataService,
    public dialog: MatDialog
  ) {}

  ngOnChanges(): void {
    this.generateHCL();
  }

  onResize(event) {
    this.createChart(this.hierObsData, this.obsTreeContainerId);
  }

  /**
   * Function to retrieve data for Observation HCL plot
   */
  generateHCL() {
    const obsResourceId = this.outputs.observations_hcl;
    this.customObservationSets = this.metadataService.getCustomObservationSets();
    this.apiService.getResourceContent(obsResourceId).subscribe(response => {
      this.hierObsData = response;
      this.createChart(this.hierObsData, this.obsTreeContainerId);
    });
  }

  /**
   * Function to create dendrogram
   */
  private createChart(hierData: any, containerId: string): void {
    if (!hierData) return;

    const outerWidth = this.svgElement.nativeElement.offsetWidth;
    const outerHeight = this.outerHeight;
    const width = outerWidth - this.margin.left - this.margin.right;
    const height = outerHeight - this.margin.top - this.margin.bottom;

    d3.select(containerId)
      .selectAll('svg')
      .remove();

    const root = d3.hierarchy(hierData);
    let ifCustomObservationSetExists = false;
    root.leaves().map(leaf => {
      const sample = leaf.data.name;
      leaf.data.isLeaf = true;
      leaf.data.colors = [];
      this.customObservationSets.forEach(set => {
        if (set.elements.some(e => e.id === sample)) {
          ifCustomObservationSetExists = true;
          leaf.data.colors.push(set.color);
        }
      });
    });
    const leafNodeNumber = root.leaves().length; // calculate the number of nodes
    // add extra px for every node above 20 to the set height
    const addHeight = leafNodeNumber > 20 ? (leafNodeNumber - 20) * 30 : 0;
    const canvasHeight = height + addHeight;
    const tree = d3.cluster().size([canvasHeight, width - 200]);
    tree(root);
    const svg = d3
      .select(containerId)
      .append('svg')
      .attr('width', outerWidth)
      .attr('height', outerHeight + addHeight)
      .append('g')
      .attr(
        'transform',
        'translate(' + this.margin.left + ',' + this.margin.top + ')'
      )
      .style('fill', 'none');

    svg
      .selectAll('path.link')
      .data(root.descendants().slice(1))
      .enter()
      .append('path')
      .attr('class', 'link')
      .attr('d', elbow);

    const that = this;
    const node = svg
      .selectAll('g.node')
      .data(root.descendants())
      .enter()
      .append('g')
      .attr('class', 'node')
      .attr('transform', d => 'translate(' + d['y'] + ',' + d['x'] + ')')
      .on('click', highlightNodes);

    node
      .append('circle')
      .filter(d => d.data.name.length > 0)
      .attr('r', 4);

    const leafNode = node.filter(d => d.data.isLeaf === true);

    function highlightNodes(event, d: any) {
      d.descendants().forEach(
        node =>
          (node.data.isHighlighted = node.data.isHighlighted ? false : true)
      );

      that.selectedSamples = root
        .leaves()
        .filter(leaf => leaf.data.isHighlighted)
        .map(leaf => leaf.data.name);

      d3.select(containerId)
        .selectAll('circle')
        .attr('class', (d: any) => {
          if (d.data.isHighlighted) return 'highlighted';
        });
    }

    // Tooltip
    const tooltipOffsetX = this.tooltipOffsetX;
    const tip = d3Tip()
      .attr('class', 'd3-tip')
      .offset([-10, 0])
      .html((event, d) => d.data.name);
    svg.call(tip);

    const truncate = input =>
      input.length > this.maxTextLabelLength
        ? `${input.substring(0, this.maxTextLabelLength)}...`
        : input;
    leafNode
      .append('text')
      .attr('dx', 10)
      .attr('dy', 3)
      .attr('class', 'textLabel')
      .text(d => truncate(d.data.name))
      .on('mouseover', function(mouseEvent: any, d) {
        tip.show(mouseEvent, d, this);
        tip.style('left', mouseEvent.x + tooltipOffsetX + 'px');
      })
      .on('mouseout', tip.hide);

    // Color squares for leaf nodes to indicate custom sample sets
    leafNode
      .append('g')
      .attr('width', 200)
      .each((d, ix, nodes) => {
        d3.select(nodes[ix])
          .selectAll('sampleSetColors')
          .data(d['data'].colors)
          .enter()
          .append('rect')
          .attr('x', (el, i) => 100 + 30 * i)
          .attr('y', -10)
          .attr('height', '20')
          .attr('width', '20')
          .attr('fill', (d: string) => d || 'transparent');
      });

    // Legend (only if custom observations exist)
    if (ifCustomObservationSetExists) {
      const legend = svg
        .selectAll('.legend')
        .data(this.customObservationSets)
        .enter()
        .append('g')
        .classed('legend', true)
        .attr('transform', function(d, i) {
          return 'translate(0,' + i * 20 + ')';
        });

      legend
        .append('rect')
        .attr('width', 10)
        .attr('height', 10)
        .attr('x', width + 50)
        .attr('fill', d => d.color);

      legend
        .append('text')
        .attr('x', width + 70)
        // .attr('dy', '.5em')
        .attr('y', 8)
        .style('fill', '#000')
        .attr('class', 'legend-label')
        .text(d => d.name);
    }

    function elbow(d) {
      return 'M' + d.parent.y + ',' + d.parent.x + 'V' + d.x + 'H' + d.y;
    }
  }

  /**
   * Function that is triggered when the user clicks the "Create a custom sample" button
   */
  onCreateCustomSampleSet() {
    let samples = this.selectedSamples.map(elem => ({ id: elem }));
    const dialogRef = this.dialog.open(AddSampleSetComponent, {
      data: { type: CustomSetType.ObservationSet }
    });

    dialogRef.afterClosed().subscribe(customSetData => {
      if (customSetData) {
        const customSet = {
          name: customSetData.name,
          type: CustomSetType.ObservationSet,
          color: customSetData.color,
          elements: samples,
          multiple: true
        };

        // if the custom set has been successfully added, update the plot
        if (this.metadataService.addCustomSet(customSet)) {
          this.generateHCL();
          this.selectedSamples = [];
        }
      }
    });
  }
}
<mat-card class="analysis-card">
    <mat-card-header>
        <div mat-card-avatar class="analysis-card__img"></div>
        <mat-card-title>Hierarchical clustering: {{ outputs?.job_name }}</mat-card-title>
    </mat-card-header>
    <mat-card-content class="analysis-card__main">
        <p class="analysis-card__instruction">
            Click on a node in the dendrogram graph to select multiple observations. Then click the Add button to
            save it as a new custom set.
        </p>
        <mat-divider [inset]="true"></mat-divider>
        <div class="analysis-card__content">

            <div class="hcl-container">

                <div class="chart-section">
                    <div class="chart-title">Observation clustering</div>                    
                    <div class="sample-list-container">
                        <button mat-raised-button color="accent" (click)="onCreateCustomSampleSet()"
                            [disabled]="!selectedSamples.length">
                            <mat-icon>add</mat-icon>
                            Save as a observation set
                        </button>
                        <div class="sample-list" *ngIf="selectedSamples.length"> Selected samples:
                            <span class="sample-list__item" *ngFor="let item of selectedSamples">
                                {{ item }}
                            </span>
                        </div>
                    </div>
                    <mev-download-button [containerId]="obsTreeContainerId" [imageName]="obsImageName"></mev-download-button>
                    <div class="chart" #treePlot id="observationPlot" (window:resize)="onResize($event)"></div>

                </div>
                <mat-divider [inset]="true"></mat-divider>
            </div>
        </div>
    </mat-card-content>
</mat-card>

./hcl.component.scss

.analysis-card__img {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' aria-hidden='true' focusable='false' width='1em' height='1em' style='-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);' preserveAspectRatio='xMidYMid meet' viewBox='0 0 32 32'%3E%3Cpath d='M26 6a3.996 3.996 0 0 0-3.858 3H17.93A7.996 7.996 0 1 0 9 17.93v4.212a4 4 0 1 0 2 0v-4.211a7.951 7.951 0 0 0 3.898-1.62l3.669 3.67A3.953 3.953 0 0 0 18 22a4 4 0 1 0 4-4a3.952 3.952 0 0 0-2.019.567l-3.67-3.67A7.95 7.95 0 0 0 17.932 11h4.211A3.993 3.993 0 1 0 26 6zM12 26a2 2 0 1 1-2-2a2.002 2.002 0 0 1 2 2zm-2-10a6 6 0 1 1 6-6a6.007 6.007 0 0 1-6 6zm14 6a2 2 0 1 1-2-2a2.002 2.002 0 0 1 2 2zm2-10a2 2 0 1 1 2-2a2.002 2.002 0 0 1-2 2z' fill='%2337474f'/%3E%3C/svg%3E");
  background-size: 30px;
  background-position: top center;
  background-repeat: no-repeat;
}

.analysis-card__content {
  padding-top: 1rem;
}

mat-expansion-panel {
  width: 100%;
}

.chart-title {
  color: #37474f;
  text-transform: uppercase;
  text-align: center;
  font-size: 14px;
  font-weight: bold;
  padding: 20px;
}

.chart {
  text-align: center;
}

::ng-deep {
  .mat-content {
    display: inline !important;
    text-align: center;
  }
  .node circle {
    fill: #fff;
    stroke: #37474f;
    stroke-width: 1.5px;
  }

  .node circle:hover {
    fill: #37474f;
    cursor: pointer;
  }

  .node {
    font: 11px sans-serif;
    fill: gray;
  }

  .highlighted {
    fill: #37474f !important;
    stroke: #666 !important;
    stroke-width: 2px !important;
  }

  .link {
    fill: none;
    stroke: #ccc;
    stroke-width: 1.5px;
  }

  .rectLabel {
    fill: transparent;
    opacity: 1;
  }

  .textLabel {
    fill: #000;
  }

  .legend-label {
    font-size: 12px;
  }

  .d3-tip {
    line-height: 1;
    font-weight: bold;
    padding: 12px;
    background: rgba(0, 0, 0, 0.8);
    color: #fff;
    border-radius: 2px;
    z-index: 1;
  }
}

mat-card {
  height: 100%;
}

.sample-list {
  padding: 15px 0;
}

.sample-list__item:not(:last-child):after {
  content: ', ';
}

.status-txt {
  color: #75c075;
  padding: 5px 0;
}
Legend
Html element
Component
Html element with directive

result-matching ""

    No results matching ""