projects/web-mev/src/app/d3/components/hcl/hcl.component.ts
HCL Component
Used for Hierarchical Clustering (HCL) analysis
| changeDetection | ChangeDetectionStrategy.Default |
| selector | mev-hcl |
| styleUrls | ./hcl.component.scss |
| templateUrl | ./hcl.component.html |
Properties |
Methods |
Inputs |
constructor(apiService: AnalysesService, metadataService: MetadataService, dialog: MatDialog)
|
||||||||||||
|
Parameters :
|
| outputs | |
| Private createChart |
createChart(hierData: any, containerId: string)
|
|
Function to create dendrogram
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 :
Returns :
void
|
| 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;
}