projects/web-mev/src/app/d3/components/kmeans/kmeans/kmeans.component.ts
K-Means Component
Used for K-Means analysis
| changeDetection | ChangeDetectionStrategy.Default |
| selector | mev-kmeans |
| styleUrls | ./kmeans.component.scss |
| templateUrl | ./kmeans.component.html |
constructor(dialog: MatDialog, apiService: AnalysesService, metadataService: MetadataService)
|
||||||||||||
|
Parameters :
|
| outputs | |
| Private createChart |
createChart()
|
|
Function to create scatter plot
Returns :
void
|
| generateScatterPlot |
generateScatterPlot()
|
|
Function to retrieve data for scatter plot
Returns :
void
|
| isCustomObservationSetChecked | ||||
isCustomObservationSetChecked(setName)
|
||||
|
Parameters :
Returns :
any
|
| isDimensionByFeatures |
isDimensionByFeatures()
|
|
Function to check the dimension used for clustering (features or samples)
Returns :
boolean
|
| ngOnChanges |
ngOnChanges()
|
|
Returns :
void
|
| onCreateCustomSampleSet |
onCreateCustomSampleSet()
|
|
Function that is triggered when the user clicks the "Create a custom sample" button
Returns :
void
|
| onObservationCheck | ||||
onObservationCheck(e)
|
||||
|
Parameters :
Returns :
void
|
| onResize | ||||
onResize(event)
|
||||
|
Parameters :
Returns :
void
|
| reformatData |
reformatData()
|
|
Function to prepare data for Scatter plot
Returns :
void
|
| zoomHandler | ||||
zoomHandler(event)
|
||||
|
Function that is triggered when zooming
Parameters :
Returns :
void
|
| brushListener |
| centroids |
Type : []
|
Default value : []
|
| clusterSelected |
| containerId |
Type : string
|
Default value : '#scatterPlot'
|
| customObservationSets |
Type : []
|
Default value : []
|
| Public dialog |
Type : MatDialog
|
| gX |
| gY |
| imageName |
Type : string
|
Default value : 'K-means'
|
| margin |
Type : object
|
Default value : { top: 50, right: 300, bottom: 50, left: 70 }
|
| maxPointNumber |
Default value : 10 ** 4
|
| outerHeight |
Type : number
|
Default value : 500
|
| pointCat |
Type : string
|
Default value : 'id'
|
| precision |
Type : number
|
Default value : 2
|
| sampleColorMap |
Type : object
|
Default value : {}
|
| sampleSetColors |
Type : []
|
Default value : []
|
| scatterData |
| scatterDataFormatted |
| selectedSamples |
Type : []
|
Default value : []
|
| svgElement |
Type : ElementRef
|
Decorators :
@ViewChild('scatterPlot')
|
| xAxis |
| xCat |
Type : string
|
Default value : 'x'
|
| xScale |
| yAxis |
| yCat |
Type : string
|
Default value : 'y'
|
| yScale |
| zoomListener |
| zoomTransform |
import {
Component,
ChangeDetectionStrategy,
Input,
ViewChild,
ElementRef,
OnChanges
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { AnalysesService } from '@app/features/analysis/services/analysis.service';
import { MetadataService } from '@app/core/metadata/metadata.service';
import * as d3 from 'd3';
import d3Tip from 'd3-tip';
import { AddSampleSetComponent } from '../../dialogs/add-sample-set/add-sample-set.component';
import { CustomSet, CustomSetType } from '@app/_models/metadata';
import { Utils } from '@app/shared/utils/utils';
/**
* K-Means Component
*
* Used for K-Means analysis
*/
@Component({
selector: 'mev-kmeans',
templateUrl: './kmeans.component.html',
styleUrls: ['./kmeans.component.scss'],
changeDetection: ChangeDetectionStrategy.Default
})
export class KmeansComponent implements OnChanges {
@Input() outputs;
@ViewChild('scatterPlot') svgElement: ElementRef;
scatterData;
scatterDataFormatted;
selectedSamples = [];
customObservationSets = [];
centroids = [];
sampleColorMap = {}; // mapping individual samples and colors (used for points in scatter plot)
sampleSetColors = []; // the list of sample sets and their colors (used for legend in scatter plot)
/* Chart settings */
containerId = '#scatterPlot';
imageName = 'K-means'; // file name for downloaded SVG image
maxPointNumber = 10 ** 4;
precision = 2;
margin = { top: 50, right: 300, bottom: 50, left: 70 }; // chart margins
outerHeight = 500;
pointCat = 'id'; // data field used to label individual points
/* D3 chart variables */
xAxis; // axes
yAxis;
clusterSelected;
xCat = 'x'; // field name in data for X axis (it's 'pc1' for default view)
yCat = 'y'; // field name in data for Y axis (it's 'pc2' for default view)
xScale; // scale functions to transform data values into the the range
yScale;
gX; // group elements for all the components of the X and Y-axis
gY;
zoomListener;
brushListener;
zoomTransform;
constructor(
public dialog: MatDialog,
private apiService: AnalysesService,
private metadataService: MetadataService
) {}
ngOnChanges(): void {
this.customObservationSets = this.metadataService.getCustomObservationSets();
this.generateScatterPlot();
}
onResize(event) {
this.createChart();
}
/**
* Function to retrieve data for scatter plot
*/
generateScatterPlot() {
const resourceId = this.outputs.kmeans_results;
this.apiService
.getResourceContent(resourceId, 1, this.maxPointNumber)
.subscribe(response => {
this.scatterData = {
...response
};
this.reformatData();
this.createChart();
});
}
/**
* Function to prepare data for Scatter plot
*/
reformatData() {
const centroidColors = {};
this.scatterData.centroids.forEach(el => {
centroidColors[el.cluster_id] = Utils.getColorScheme()[el.cluster_id];
});
this.centroids = this.scatterData.centroids.map(centroid => ({
...centroid,
id: 'Centroid ' + (parseInt(centroid.cluster_id) + 1),
isCentroid: true,
color: centroidColors[centroid.cluster_id]
}));
const points = this.scatterData.points.map(point => ({
...point,
color: centroidColors[point.cluster_id]
}));
this.scatterDataFormatted = {
scatterPoints: points.concat(this.centroids)
};
}
/**
* Function to create scatter plot
*/
private createChart(): void {
const delta = 0.1; // used for X and Y axis ranges (we add delta to avoid bug when both max and min are zeros)
const zoomFactor = 50;
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;
const data = this.scatterDataFormatted.scatterPoints;
d3.select(this.containerId)
.selectAll('svg')
.remove();
/* Setting up X-axis and Y-axis*/
this.xScale = d3
.scaleLinear()
.rangeRound([0, width])
.nice();
this.yScale = d3
.scaleLinear()
.rangeRound([height, 0])
.nice();
const xMax = d3.max(data, d => <number>d[this.xCat]);
const xMin = d3.min(data, d => <number>d[this.xCat]);
const yMax = d3.max(data, d => <number>d[this.yCat]);
const yMin = d3.min(data, d => <number>d[this.yCat]);
const xRange = xMax - xMin + delta; // add delta to avoid bug when both max and min are zeros
const yRange = yMax - yMin + delta;
this.xScale.domain([xMin - xRange * delta, xMax + xRange * delta]);
this.yScale.domain([yMin - yRange * delta, yMax + yRange * delta]);
this.xAxis = d3.axisBottom(this.xScale).tickSize(-height);
this.yAxis = d3.axisLeft(this.yScale).tickSize(-width);
// Add the Zoom and panning feature
this.zoomListener = d3
.zoom()
.scaleExtent([0, zoomFactor])
.on('zoom', event => this.zoomHandler(event));
const group = d3
.select(this.containerId)
.append('svg')
.attr('width', outerWidth)
.attr('height', outerHeight)
.append('g')
.attr(
'transform',
'translate(' + this.margin.left + ',' + this.margin.top + ')'
)
.style('fill', 'none')
.call(this.zoomListener);
// Add the brush feature
this.brushListener = d3
.brush()
.filter(
event =>
!event['ctrlKey'] && !event['button'] && event['target'].__data__
)
.extent([
[this.margin.left, this.margin.top],
[this.margin.left + width, this.margin.top + height]
]);
// Tooltip
const tip = d3Tip()
.attr('class', 'd3-tip')
.offset([-10, 0])
.html((event, d) => {
return (
d[this.pointCat] +
'<br>' +
this.xCat +
': ' +
d[this.xCat].toFixed(this.precision) +
'<br>' +
this.yCat +
': ' +
d[this.yCat].toFixed(this.precision)
);
});
group.call(tip);
group
.append('rect')
.attr('width', width)
.attr('height', height)
.style('fill', 'transparent');
this.gX = group
.append('g')
.classed('x axis', true)
.attr('transform', 'translate(0,' + height + ')')
.call(this.xAxis);
this.gX
.append('text')
.classed('label', true)
.attr('x', width)
.attr('y', this.margin.bottom - 10)
.style('text-anchor', 'end')
.text(this.xCat.toUpperCase());
this.gY = group
.append('g')
.classed('y axis', true)
.call(this.yAxis);
this.gY
.append('text')
.classed('label', true)
.attr('dx', '-2em')
.attr('dy', '.71em')
.style('text-anchor', 'end')
.text(this.yCat.toUpperCase());
const objects = group
.append('svg')
.classed('objects', true)
.attr('width', width)
.attr('height', height);
objects
.selectAll('.dot')
.data(data)
.enter()
.append('circle')
.classed('dot', true)
.attr('r', d => (d['isCentroid'] ? 10 : 7))
.attr(
'transform',
d =>
'translate(' +
this.xScale(d[this.xCat]) +
',' +
this.yScale(d[this.yCat]) +
')'
)
.style('fill', d => {
return this.sampleColorMap[d[this.pointCat]] || d['color'];
})
.attr('stroke', d =>
this.sampleColorMap[d[this.pointCat]] === 'transparent' ? '#000' : ''
)
.attr('pointer-events', 'all')
.on('mouseover', tip.show)
.on('mouseout', tip.hide);
d3.select(this.containerId)
.select('rect.overlay')
.attr('pointer-events', null);
// Legend
const centroidColors = this.centroids.map(el => ({
name: el.id,
color: el.color
}));
const legendColors = [
...this.sampleSetColors,
...centroidColors,
{ name: 'N/A', color: 'grey' },
{ name: 'Sample belonging to 2+ groups', color: 'transparent' }
];
const legend = group
.selectAll('.legend')
.data(legendColors)
.enter()
.append('g')
.classed('legend', true)
.attr('transform', function(d, i) {
return 'translate(0,' + i * 20 + ')';
});
legend
.append('circle')
.attr('r', 5)
.attr('cx', width + 20)
.attr('fill', d => d.color)
.attr('stroke', d => (d.color !== 'transparent' ? d.color : '#000'));
legend
.append('text')
.attr('x', width + 35)
.attr('dy', '.35em')
.style('fill', '#000')
.attr('class', 'legend-label')
.text(d => d.name);
}
/**
* Function that is triggered when zooming
*/
zoomHandler(event) {
const { transform } = event;
this.zoomTransform = transform;
d3.select(this.containerId)
.selectAll('.dot')
.attr('transform', t => {
const x_coord = transform.x + transform.k * this.xScale(t[this.xCat]);
const y_coord = transform.y + transform.k * this.yScale(t[this.yCat]);
return 'translate(' + x_coord + ',' + y_coord + ')';
});
this.gX.call(this.xAxis.scale(transform.rescaleX(this.xScale)));
this.gY.call(this.yAxis.scale(transform.rescaleY(this.yScale)));
}
/**
* Function that is triggered when the user clicks the "Create a custom sample" button
*/
onCreateCustomSampleSet() {
const selectedSamples = this.scatterData.points
.filter(point => point.cluster_id === this.clusterSelected)
.map(point => ({ id: point.id }));
const dialogRef = this.dialog.open(AddSampleSetComponent);
dialogRef.afterClosed().subscribe(customSetData => {
if (customSetData) {
const observationSet: CustomSet = {
name: customSetData.name,
type: CustomSetType.ObservationSet,
color: customSetData.color,
elements: selectedSamples,
multiple: true
};
if (this.metadataService.addCustomSet(observationSet)) {
this.customObservationSets = this.metadataService.getCustomObservationSets();
}
}
});
}
onObservationCheck(e) {
const sampleSet = e.source.id;
const foundSet = this.customObservationSets.find(
el => el.name === sampleSet
);
this.sampleColorMap = {};
if (e.checked) {
this.sampleSetColors.push(foundSet);
const sampleSets = this.sampleSetColors;
sampleSets.forEach(set => {
const samples = set.elements.map(el => el.id);
samples.forEach(sample => {
this.sampleColorMap[sample] = !this.sampleColorMap[sample]
? set.color
: 'transparent';
});
});
} else {
this.sampleSetColors = this.sampleSetColors.filter(
set => set.name !== foundSet.name
);
this.sampleSetColors.forEach(set => {
const samples = set.elements.map(el => el.id);
samples.forEach(sample => {
this.sampleColorMap[sample] = !this.sampleColorMap[sample]
? set.color
: 'transparent';
});
});
}
// this.generateScatterPlot();
this.createChart();
}
isCustomObservationSetChecked(setName) {
return this.sampleSetColors.find(set => set.name === setName);
}
/**
* Function to check the dimension used for clustering (features or samples)
*/
isDimensionByFeatures() {
const dimension = this.outputs.dimension.toLowerCase();
if (dimension.indexOf('feature') >= 0) return true;
return false;
}
}
<mat-card class="analysis-card">
<mat-card-header>
<div mat-card-avatar class="analysis-card__img"></div>
<mat-card-title>K-means analysis: {{ outputs?.job_name }}</mat-card-title>
</mat-card-header>
<mat-card-content class="analysis-card__main">
<p class="analysis-card__instruction">
Please note that we use principal components analysis (PCA) to visualize the clusters determined through the K-means algorithm. This view is illustrative and there may be instances where this visualization does not accurately represent the clusters determined in high-dimensional space.
</p>
<mat-divider [inset]="true"></mat-divider>
<div class="analysis-card__content">
<div *ngIf="scatterDataFormatted" class="dropdown-container">
<span class="dropdown-label">Select a cluster and click the Save button: </span>
<mat-select class="dropdown" [(ngModel)]="clusterSelected">
<mat-option *ngFor="let centroid of centroids; index as i; " [value]="centroid.cluster_id">
{{ centroid.id }}
</mat-option>
</mat-select>
<button mat-raised-button color="accent" (click)="onCreateCustomSampleSet()"
[disabled]="clusterSelected === undefined || isDimensionByFeatures()">
<mat-icon>add</mat-icon>
Save as a sample set
</button>
<div class="sample-list" *ngIf="selectedSamples.length"> Selected samples:
<span class="sample-list__item" *ngFor="let item of selectedSamples">
{{ item.sample }}
</span>
</div>
</div>
<section class="observation-list-section">
<ul *ngIf="customObservationSets.length"> Conditional formatting:
<li *ngFor="let set of customObservationSets">
<mat-checkbox (change)="onObservationCheck($event)" [checked]="isCustomObservationSetChecked(set.name)" [id]="set.name">{{set.name}}</mat-checkbox>
</li>
</ul>
</section>
<mev-download-button [containerId]="containerId" [imageName]="imageName"></mev-download-button>
<div #scatterPlot id="scatterPlot" class="chart" (window:resize)="onResize($event)"></div>
</div>
</mat-card-content>
</mat-card>
./kmeans.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 48 48'%3E%3Cpath fill='%2337474f' d='M9 39V6H7v35h35v-2z'/%3E%3Cg fill='%2337474f'%3E%3Ccircle cx='39' cy='11' r='3'/%3E%3Ccircle cx='31' cy='13' r='3'/%3E%3Ccircle cx='37' cy='19' r='3'/%3E%3Ccircle cx='34' cy='26' r='3'/%3E%3Ccircle cx='28' cy='20' r='3'/%3E%3Ccircle cx='26' cy='28' r='3'/%3E%3Ccircle cx='20' cy='23' r='3'/%3E%3Ccircle cx='21' cy='33' r='3'/%3E%3Ccircle cx='14' cy='30' r='3'/%3E%3C/g%3E%3C/svg%3E");
background-size: 30px;
background-position: top center;
background-repeat: no-repeat;
}
.analysis-card__content {
padding-top: 1rem;
}
.chart {
text-align: center;
}
rect {
fill: transparent;
shape-rendering: crispEdges;
}
::ng-deep {
.axis path,
.axis line {
fill: none;
stroke: rgba(0, 0, 0, 0.1);
shape-rendering: crispEdges;
}
.label {
fill: initial;
font-size: 16px;
}
.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;
}
.selected {
fill: red;
stroke: black;
stroke-width: 2px;
}
}
.mat-button-toggle {
width: 200px;
}
.mat-button-toggle-checked {
background-color: #c8ebfa !important;
}
.dropdown {
width: 100px;
padding: 0 5px;
margin: 0 30px 0 0;
border-bottom: 1px solid #ccc;
}
.observation-list-section {
margin: 20px 5px;
ul {
padding-left: 0;
}
li {
display: inline;
padding: 15px;
}
}
.sample-list {
padding: 15px 0;
}
.sample-list__item:not(:last-child):after {
content: ', ';
}
.status-txt {
color: #75c075;
}