File

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

Description

Scatter Plot Component

Used for Principal Component Analyses (PCA)

Implements

OnChanges

Metadata

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

Index

Properties
Methods
Inputs

Constructor

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

Inputs

outputs

Methods

brushHandler
brushHandler(event)

Function that is triggered when brushing is performed

Parameters :
Name Optional
event No
Returns : void
Private createChart
createChart()

Function to create scatter plot

Returns : void
generatePCAPlot
generatePCAPlot()

Function to retrieve data for PCA plot

Returns : void
isBrushed
isBrushed(brush_coords, cx, cy)

Function that returns TRUE or FALSE according if a point is in the selected area

Parameters :
Name Optional
brush_coords No
cx No
cy No
Returns : boolean
isCustomObservationSetChecked
isCustomObservationSetChecked(setName)
Parameters :
Name Optional
setName No
Returns : any
ngOnChanges
ngOnChanges()
Returns : void
onChartViewChange
onChartViewChange(chartViewMode)

Function is triggered when switching between Zooming and Selection view modes

Parameters :
Name Optional
chartViewMode No
Returns : void
onCreateCustomSampleSet
onCreateCustomSampleSet()

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

Returns : void
onObservationCheck
onObservationCheck(e)
Parameters :
Name Optional
e No
Returns : void
onResize
onResize(event)
Parameters :
Name Optional
event No
Returns : void
onXAxisChange
onXAxisChange(index)

Function that is triggered when principal component for X axis is changed

Parameters :
Name Optional
index No
Returns : void
onYAxisChange
onYAxisChange(index)

Function that is triggered when principal component for Y axis is changed

Parameters :
Name Optional
index No
Returns : void
reformatData
reformatData()

Function to prepare data for PCA plot

Returns : void
updateXCatAndVarianceByIndex
updateXCatAndVarianceByIndex(index)

Function to update xCat and xVariance for X axis

Parameters :
Name Optional
index No
Returns : void
updateYCatAndVarianceByIndex
updateYCatAndVarianceByIndex(index)

Function to update yCat and yVariance for Y axis

Parameters :
Name Optional
index No
Returns : void
zoomHandler
zoomHandler(event)

Function that is triggered when zooming

Parameters :
Name Optional
event No
Returns : void

Properties

brushListener
chartViewMode
Type : string
Default value : 'zoomMode'
containerId
Type : string
Default value : '#scatterPlot'
customObservationSets
Type : []
Default value : []
Public dialog
Type : MatDialog
gX
gY
imageName
Type : string
Default value : 'PCA'
margin
Type : object
Default value : { top: 50, right: 300, bottom: 50, left: 70 }
maxPointNumber
Default value : 10 ** 4
outerHeight
Type : number
Default value : 500
pcaData
pcaDataFormatted
pointCat
Type : string
Default value : 'sample'
precision
Type : number
Default value : 2
sampleColorMap
Type : object
Default value : {}
sampleSetColors
Type : []
Default value : []
selectedSamples
Type : []
Default value : []
svgElement
Type : ElementRef
Decorators :
@ViewChild('scatterPlot')
xAxis
xCat
xCatIndex
Type : number
xScale
xVariance
yAxis
yCat
yCatIndex
Type : number
yScale
yVariance
zoomListener
zoomTransform
import {
  Component,
  ChangeDetectionStrategy,
  Input,
  OnChanges,
  ElementRef,
  ViewChild
} from '@angular/core';
import * as d3 from 'd3';
import d3Tip from 'd3-tip';
import { MatDialog } from '@angular/material/dialog';
import { AddSampleSetComponent } from '../dialogs/add-sample-set/add-sample-set.component';
import { AnalysesService } from '@app/features/analysis/services/analysis.service';
import { MetadataService } from '@app/core/metadata/metadata.service';
import { CustomSetType, CustomSet } from '@app/_models/metadata';

/**
 * Scatter Plot Component
 *
 * Used for Principal Component Analyses (PCA)
 */
@Component({
  selector: 'mev-scatter-plot',
  templateUrl: './scatter-plot.component.html',
  styleUrls: ['./scatter-plot.component.scss'],
  changeDetection: ChangeDetectionStrategy.Default
})
export class ScatterPlotComponent implements OnChanges {
  @Input() outputs;
  @ViewChild('scatterPlot') svgElement: ElementRef;
  pcaData;
  pcaDataFormatted;

  selectedSamples = [];
  customObservationSets = [];
  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 = 'PCA'; // file name for downloaded SVG image
  maxPointNumber = 10 ** 4;
  precision = 2;
  chartViewMode = 'zoomMode'; // default chart view mode
  margin = { top: 50, right: 300, bottom: 50, left: 70 }; // chart margins
  outerHeight = 500;

  pointCat = 'sample'; // data field used to label individual points

  /* D3 chart variables */
  xAxis; // axes
  yAxis;
  xCat; // field name in data for X axis (it's 'pc1' for default view)
  yCat; // field name in data for Y axis (it's 'pc2' for default view)
  xCatIndex: number; // order of prinicipal component for X axis (0 for default view)
  yCatIndex: number; // order of prinicipal component for Y axis (1 for default view)
  xVariance; // explained variance for X and Y axes
  yVariance;
  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.generatePCAPlot();
  }

  onResize(event) {
    this.createChart();
  }

  /**
   * Function to retrieve data for PCA plot
   */
  generatePCAPlot() {
    this.chartViewMode = 'zoomMode'; // default mode
    const resourceId = this.outputs.pca_coordinates;
    const pca_explained_variances = [];
    let i = 1;
    while (this.outputs.hasOwnProperty('pc' + i + '_explained_variance')) {
      const item = {
        name: 'pc' + i,
        var: this.outputs['pc' + i + '_explained_variance']
      };
      pca_explained_variances.push(item);
      i++;
    }

    this.apiService
      .getResourceContent(resourceId, 1, this.maxPointNumber)
      .subscribe(response => {
        this.pcaData = {
          ...response,
          pca_explained_variances: pca_explained_variances
        };
        this.reformatData();
        this.createChart();
      });
  }

  /**
   * Function to prepare data for PCA plot
   */
  reformatData() {
    const results = this.pcaData.results;

    const newPoints = [];
    if (results.length > 0 && results[0].values) {
      const sampleNames = Object.keys(results[0].values);
      sampleNames.forEach(sampleName => {
        const newPoint = { sample: sampleName };
        results.forEach(el => {
          const pc = el.rowname;
          const val = el.values[sampleName];
          newPoint[pc] = val;
        });
        newPoints.push(newPoint);
      });
    }

    this.pcaDataFormatted = {
      pcaPoints: newPoints,
      axisInfo: this.pcaData.pca_explained_variances
    };
    // initialise variables for X and Y axes if undefined
    // by default use the 1st principal component for X axis and the 2nd for Y axis
    if (!this.xCat && !this.yCat) {
      this.updateXCatAndVarianceByIndex(0);
      this.updateYCatAndVarianceByIndex(1);
    }
  }

  /**
   * Function is triggered when switching between Zooming and Selection view modes
   */
  onChartViewChange(chartViewMode) {
    this.chartViewMode = chartViewMode;
    const svg = d3.select(this.containerId).select('svg');

    if (chartViewMode === 'selectionMode') {
      // activate brushing
      svg.call(this.brushListener);

      // deactivate zooming
      svg.select('g').call(d3.zoom().on('zoom', null));
    }
    if (chartViewMode === 'zoomMode') {
      svg.select('g').call(this.zoomListener);

      // reset the brush area to an empty area and deactivate brushing feature
      const brush = d3
        .brush()
        .filter(
          event =>
            !event['ctrlKey'] && !event['button'] && event['target'].__data__
        )
        .on('start', null);

      svg.call(
        brush.extent([
          [0, 0],
          [0, 0]
        ])
      );
      svg.call(brush.move, null);
    }
  }

  /**
   * 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.pcaDataFormatted.pcaPoints;

    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]
      ])
      .on('start end', event => this.brushHandler(event));

    // 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() +
          ' (Explained variance: ' +
          this.xVariance +
          ')'
      );

    this.gY = group
      .append('g')
      .classed('y axis', true)
      .call(this.yAxis);

    this.gY
      .append('text')
      .classed('label', true)
      .attr('transform', 'rotate(-90)')
      .attr('y', -this.margin.left)
      .attr('dy', '.71em')
      .style('text-anchor', 'end')
      .text(
        this.yCat.toUpperCase() +
          ' (Explained variance: ' +
          this.yVariance +
          ')'
      );

    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', 7)
      .attr(
        'transform',
        d =>
          'translate(' +
          this.xScale(d[this.xCat]) +
          ',' +
          this.yScale(d[this.yCat]) +
          ')'
      )
      .style('fill', d => {
        return this.sampleColorMap[d[this.pointCat]] || 'grey';
      })
      .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('group')
    //   .call(this.brushListener);
    d3.select(this.containerId)
      .select('rect.overlay')
      .attr('pointer-events', null);

    // Legend
    const legendColors = [
      ...this.sampleSetColors,
      { 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 brushing is performed
   */
  brushHandler(event) {
    const extent = event.selection; // get the selection coordinate
    this.selectedSamples = [];
    d3.select(this.containerId)
      .selectAll('.dot')
      .classed('selected', d => {
        let x_coord = this.xScale(d[this.xCat]);
        let y_coord = this.yScale(d[this.yCat]);
        if (this.zoomTransform) {
          // recalculate coordinates if zooming has been performed
          x_coord = this.zoomTransform.x + this.zoomTransform.k * x_coord;
          y_coord = this.zoomTransform.y + this.zoomTransform.k * y_coord;
        }

        if (this.isBrushed(extent, x_coord, y_coord)) {
          this.selectedSamples.push({
            ...(d as object),
            x_coord: d[this.xCat],
            y_coord: d[this.yCat]
          });
          return true;
        }
        return false;
      });
  }

  /**
   * Function that returns TRUE or FALSE according if a point is in the selected area
   */
  isBrushed(brush_coords, cx, cy) {
    if (brush_coords) {
      const x0 = brush_coords[0][0] - this.margin.left,
        x1 = brush_coords[1][0] - this.margin.left,
        y0 = brush_coords[0][1] - this.margin.top,
        y1 = brush_coords[1][1] - this.margin.top;
      return x0 <= cx && cx <= x1 && y0 <= cy && cy <= y1;
    }
    return false;
  }

  /**
   * Function that is triggered when principal component for X axis is changed
   */
  onXAxisChange(index) {
    this.updateXCatAndVarianceByIndex(index);
    this.createChart();
  }

  /**
   * Function that is triggered when principal component for Y axis is changed
   */
  onYAxisChange(index) {
    this.updateYCatAndVarianceByIndex(index);
    this.createChart();
  }

  /**
   * Function to update xCat and xVariance for X axis
   */
  updateXCatAndVarianceByIndex(index) {
    this.xCatIndex = index;
    this.xCat = this.pcaDataFormatted.axisInfo[index].name;
    this.xVariance = this.pcaDataFormatted.axisInfo[index].var.toFixed(
      this.precision
    );
  }

  /**
   * Function to update yCat and yVariance for Y axis
   */
  updateYCatAndVarianceByIndex(index) {
    this.yCatIndex = index;
    this.yCat = this.pcaDataFormatted.axisInfo[index].name;
    this.yVariance = this.pcaDataFormatted.axisInfo[index].var.toFixed(
      this.precision
    );
  }

  /**
   * Function that is triggered when the user clicks the "Create a custom sample" button
   */
  onCreateCustomSampleSet() {
    const samples = this.selectedSamples.map(elem => {
      const sample = { id: elem.sample };
      return sample;
    });

    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: samples,
          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.generatePCAPlot();
  }

  isCustomObservationSetChecked(setName) {
    return this.sampleSetColors.find(set => set.name === setName);
  }
}
<mat-card class="analysis-card">
    <mat-card-header>
        <div mat-card-avatar class="analysis-card__img"></div>
        <mat-card-title>Principal component analysis: {{ outputs?.job_name }}</mat-card-title>
    </mat-card-header>
    <mat-card-content class="analysis-card__main">
        <p class="analysis-card__instruction">
            To select points click the toggle button to enable the Selection mode. Then click the Add button to save a custom sample set.
        </p>
        <mat-divider [inset]="true"></mat-divider>
        <div class="analysis-card__content">
            <mat-button-toggle-group #chartMode="matButtonToggleGroup" [(ngModel)]="chartViewMode">
                <mat-button-toggle value="zoomMode" (change)="onChartViewChange($event.value)"
                    aria-label="Text align center">
                    <mat-icon aria-label="Zooming/panning">zoom_in</mat-icon> Zooming/panning
                </mat-button-toggle>
                <mat-button-toggle value="selectionMode" (change)="onChartViewChange($event.value)"
                    aria-label="Text align left">
                    <mat-icon aria-label="Select points">near_me</mat-icon> Select points
                </mat-button-toggle>
            </mat-button-toggle-group>

            <div *ngIf="pcaDataFormatted" class="axis-dropdown-container">
                <span class="axis-dropdown-label">X axis: </span>
                <mat-select class="axis-dropdown" (selectionChange)="onXAxisChange($event.value)"
                    [(ngModel)]="xCatIndex">
                    <mat-option *ngFor="let item of pcaDataFormatted.axisInfo; index as i; " [value]="i">
                        {{ item.name }}
                    </mat-option>
                </mat-select>

                <span class="axis-dropdown-label">Y axis: </span>
                <mat-select class="axis-dropdown" (selectionChange)="onYAxisChange($event.value)"
                    [(ngModel)]="yCatIndex">
                    <mat-option *ngFor="let item of pcaDataFormatted.axisInfo; index as i; " [value]="i">
                        {{ item.name }}
                    </mat-option>
                </mat-select>
            </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>          
                
                <div class="sample-list-container">
                    <button mat-raised-button color="accent" (click)="onCreateCustomSampleSet()" 
                    [disabled]="!selectedSamples.length">
                        <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 }} ({{ item.x_coord.toFixed(2) }}; {{ item.y_coord.toFixed(2) }})
                        </span>
                    </div>
                </div>          

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

./scatter-plot.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;
}

.axis-dropdown-container {
  margin: 20px 5px;
}

.axis-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;
}
Legend
Html element
Component
Html element with directive

result-matching ""

    No results matching ""