diff --git a/src/app/features/charts/charts.module.ts b/src/app/features/charts/charts.module.ts index 1f1b2b02..c00cb82d 100644 --- a/src/app/features/charts/charts.module.ts +++ b/src/app/features/charts/charts.module.ts @@ -7,6 +7,7 @@ import { BoxPlotComponent, CandlestickChartComponent, MedianChartComponent, + MedianBarChartComponent, RowChartComponent, ScoreChartComponent, ScoreBarChartComponent, @@ -19,6 +20,7 @@ import { BoxPlotComponent, CandlestickChartComponent, MedianChartComponent, + MedianBarChartComponent, RowChartComponent, ScoreChartComponent, ScoreBarChartComponent, @@ -30,6 +32,7 @@ import { BoxPlotComponent, CandlestickChartComponent, MedianChartComponent, + MedianBarChartComponent, RowChartComponent, ScoreChartComponent, ScoreBarChartComponent, diff --git a/src/app/features/charts/components/index.ts b/src/app/features/charts/components/index.ts index 189d12e4..9d00708b 100644 --- a/src/app/features/charts/components/index.ts +++ b/src/app/features/charts/components/index.ts @@ -1,6 +1,7 @@ export * from './box-plot-chart'; export * from './candlestick-chart'; export * from './median-chart'; +export * from './median-barchart'; export * from './row-chart'; export * from './score-chart'; export * from './score-barchart'; diff --git a/src/app/features/charts/components/median-barchart/index.ts b/src/app/features/charts/components/median-barchart/index.ts new file mode 100644 index 00000000..a587af3d --- /dev/null +++ b/src/app/features/charts/components/median-barchart/index.ts @@ -0,0 +1 @@ +export * from './median-barchart.component'; diff --git a/src/app/features/charts/components/median-barchart/median-barchart.component.html b/src/app/features/charts/components/median-barchart/median-barchart.component.html new file mode 100644 index 00000000..cdccce2d --- /dev/null +++ b/src/app/features/charts/components/median-barchart/median-barchart.component.html @@ -0,0 +1,12 @@ +
+
+ +
+
No data is currently available.
+
+
diff --git a/src/app/features/charts/components/median-barchart/median-barchart.component.scss b/src/app/features/charts/components/median-barchart/median-barchart.component.scss new file mode 100644 index 00000000..530405ea --- /dev/null +++ b/src/app/features/charts/components/median-barchart/median-barchart.component.scss @@ -0,0 +1,26 @@ +#median-barchart { + .x-axis-label, .y-axis-label { + font-size: var(--font-size-lg); + font-weight: 700; + color: var(--color-chart-axis-label); + fill: var(--color-chart-axis-label); + } + + .y-axis .tick { + text { + font-size: var(--font-size-sm); + } + } + + .x-axis .tick { + text { + font-size: var(--font-size-md); + font-weight: 700; + } + } + + .bar-labels { + font-size: var(--font-size-sm); + text-anchor: middle; + } +} \ No newline at end of file diff --git a/src/app/features/charts/components/median-barchart/median-barchart.component.spec.ts b/src/app/features/charts/components/median-barchart/median-barchart.component.spec.ts new file mode 100644 index 00000000..6b73bc01 --- /dev/null +++ b/src/app/features/charts/components/median-barchart/median-barchart.component.spec.ts @@ -0,0 +1,219 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { MedianBarChartComponent } from './'; +import { HelperService } from '../../../../core/services'; +import { geneMock1 } from '../../../../testing'; +import { MedianExpression } from '../../../../models/genes'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +const XAXIS_LABEL = 'BRAIN REGION'; +const YAXIS_LABEL = 'LOG2CPM'; +const FULL_MOCK_DATA = geneMock1.median_expression; +const TISSUES = ['TCX', 'PHG', 'STG']; +const SMALL_MOCK_DATA = [ + { + min: -2.54173091337051, + first_quartile: -1.03358030635935, + median: 0.483801733963266, + mean: -0.517398199667356, + third_quartile: -0.0800759845652829, + max: 2.32290808871289, + tissue: TISSUES[0], + }, + { + min: -2.44077907413711, + first_quartile: -0.592671867557559, + median: 0.013739530502129, + mean: -0.0324143336865, + third_quartile: 0.49577213202412, + max: 2.23019575245731, + tissue: TISSUES[1], + }, + { + min: -5.03189866356294, + first_quartile: -1.02644563959975, + median: 0.176348063122062, + mean: -0.323038107200895, + third_quartile: 0.391874711168331, + max: 1.9113258251877, + tissue: TISSUES[2], + }, +]; + +describe('Component: BarChart - Median', () => { + let fixture: ComponentFixture; + let component: MedianBarChartComponent; + let element: HTMLElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [MedianBarChartComponent], + imports: [RouterTestingModule], + providers: [HelperService], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MedianBarChartComponent); + component = fixture.componentInstance; + }); + + const setUp = ( + data: MedianExpression[] = FULL_MOCK_DATA, + xAxisLabel: string = XAXIS_LABEL, + yAxisLabel: string = YAXIS_LABEL + ) => { + component.data = data; + component.xAxisLabel = xAxisLabel; + component.yAxisLabel = yAxisLabel; + + fixture.detectChanges(); + element = fixture.nativeElement; + const chart = element.querySelector('svg g'); + return { chart }; + }; + + it('should create', () => { + setUp(); + expect(component).toBeTruthy(); + }); + + it('should not render the chart if there is no data', () => { + const { chart } = setUp([]); + + expect(component.data?.length).toEqual(0); + expect(element.querySelector('.chart-no-data')).toBeTruthy(); + + expect(chart).toBeFalsy(); + }); + + it('should not render the chart if all values are negative', () => { + const { chart } = setUp( + SMALL_MOCK_DATA.map((obj) => { + return { ...obj, median: -1 * obj.median }; + }) + ); + + expect(element.querySelector('.chart-no-data')).toBeTruthy(); + + expect(chart).toBeFalsy(); + }); + + it('should render the chart if there is positive data', () => { + const createChartSpy = spyOn(component, 'createChart').and.callThrough(); + const { chart } = setUp(); + + expect(component.data?.length).toEqual(FULL_MOCK_DATA.length); + expect(createChartSpy).toHaveBeenCalled(); + expect(chart).toBeTruthy(); + + expect(element.querySelector('.chart-no-data')).toBeFalsy(); + }); + + it('should render the chart axes', () => { + const { chart } = setUp(); + + expect(chart?.querySelector('.x-axis')).toBeTruthy(); + expect(chart?.querySelector('.y-axis')).toBeTruthy(); + }); + + it('should render the correct number of bars', () => { + const { chart } = setUp(); + + expect(chart?.querySelectorAll('rect').length).toEqual( + FULL_MOCK_DATA.length + ); + }); + + it('should not render bars for negative values', () => { + const { chart } = setUp([ + { ...SMALL_MOCK_DATA[0], median: -0.01 }, + ...SMALL_MOCK_DATA.slice(1), + ]); + + expect(chart?.querySelectorAll('rect').length).toEqual( + SMALL_MOCK_DATA.length - 1 + ); + }); + + it('should render the bar labels', () => { + const { chart } = setUp(); + + expect(chart?.querySelectorAll('text.bar-labels').length).toEqual( + FULL_MOCK_DATA.length + ); + }); + + it('should render the labels for both axes', () => { + const { chart } = setUp(); + + expect(chart?.querySelector('.x-axis-label')?.textContent).toEqual( + XAXIS_LABEL + ); + expect(chart?.querySelector('.y-axis-label')?.textContent).toEqual( + YAXIS_LABEL + ); + }); + + it('should render the meaningful expression threshold when values are larger than threshold', () => { + const { chart } = setUp(); + + expect( + chart?.querySelector('.meaningful-expression-threshold-line') + ).toBeTruthy(); + }); + + it('should not render the meaningful expression threshold when all values are smaller than threshold', () => { + const { chart } = setUp(SMALL_MOCK_DATA); + + expect( + chart?.querySelector('.meaningful-expression-threshold-line') + ).toBeFalsy(); + }); + + it('should alphabetize the x-axis values', () => { + setUp(SMALL_MOCK_DATA); + + const sortedTissues = TISSUES.sort(); + const xAxisTicks = element + .querySelector('svg g .x-axis') + ?.querySelectorAll('.tick'); + xAxisTicks?.forEach((val, index) => { + expect(val.textContent).toEqual(sortedTissues[index]); + }); + }); + + it('should show and hide tooltip', () => { + setUp(); + + const tooltip = element.querySelector('#tooltip'); + expect(tooltip?.textContent).toBeFalsy(); + + const xAxisTick = element.querySelector('svg g .x-axis .tick'); + const mouseEnterEvent = new MouseEvent('mouseenter', { + bubbles: true, + cancelable: true, + }); + xAxisTick?.dispatchEvent(mouseEnterEvent); + + expect(tooltip?.style.display).toEqual('block'); + expect(tooltip?.textContent).toBeTruthy(); + + const mouseLeaveEvent = new MouseEvent('mouseleave', { + bubbles: true, + cancelable: true, + }); + xAxisTick?.dispatchEvent(mouseLeaveEvent); + + expect(tooltip?.style.display).toEqual('none'); + }); +}); diff --git a/src/app/features/charts/components/median-barchart/median-barchart.component.ts b/src/app/features/charts/components/median-barchart/median-barchart.component.ts new file mode 100644 index 00000000..810ac541 --- /dev/null +++ b/src/app/features/charts/components/median-barchart/median-barchart.component.ts @@ -0,0 +1,318 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { + AfterViewInit, + Component, + ElementRef, + HostListener, + Input, + OnChanges, + OnDestroy, + SimpleChanges, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import * as d3 from 'd3'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { MedianExpression } from '../../../../models'; +import { HelperService } from '../../../../core/services'; + +// -------------------------------------------------------------------------- // +// Component +// -------------------------------------------------------------------------- // +@Component({ + selector: 'median-barchart', + templateUrl: './median-barchart.component.html', + styleUrls: ['./median-barchart.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDestroy +{ + private chartInitialized = false; + private tooltipInitialized = false; + private _data: MedianExpression[] = []; + private chart!: d3.Selection; + private tooltip!: d3.Selection; + private MEANINGFUL_EXPRESSION_THRESHOLD = Math.log2(5); + private maxValueY = -1; + + private MIN_CHART_WIDTH = 500; + private CHART_HEIGHT = 350; + private chartMargin = { top: 20, right: 20, bottom: 65, left: 65 }; + private chartXScale!: d3.ScaleBand; + private chartXAxisDrawn!: d3.Selection; + private chartXAxisLabel!: d3.Selection; + private chartBars!: d3.Selection; + private chartScoreLabels!: d3.Selection; + private chartThresholdLine!: d3.Selection; + private shouldShowThresholdLine = false; + + private resizeTimer: ReturnType | number = 0; + + get data() { + return this._data; + } + @Input() set data(data: MedianExpression[]) { + this._data = data + .filter((el) => el.median && el.median > 0) + .sort((a, b) => a.tissue.localeCompare(b.tissue)); + this.maxValueY = d3.max(this._data, (d) => d.median) || 0; + this.shouldShowThresholdLine = this.MEANINGFUL_EXPRESSION_THRESHOLD <= this.maxValueY; + } + + @Input() shouldResize = true; + @Input() xAxisLabel = ''; + @Input() yAxisLabel = 'LOG2 CPM'; + + @ViewChild('medianBarChartContainer') medianBarChartContainer: ElementRef = {} as ElementRef; + @ViewChild('chart') chartRef: ElementRef = {} as ElementRef; + @ViewChild('tooltip') tooltipRef: ElementRef = {} as ElementRef; + + constructor(private helperService: HelperService) {} + + @HostListener('window:resize', ['$event.target']) + onResize() { + if (this.shouldResize && this.chartInitialized) { + const self = this; + const divSize = this.medianBarChartContainer.nativeElement.getBoundingClientRect().width; + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(() => { + self.resizeChart(divSize); + }, 100); + } + }; + + ngOnChanges(changes: SimpleChanges): void { + if ( + (changes._data && !changes._data.firstChange) || + (changes.xAxisLabel && !changes.xAxisLabel.firstChange) || + (changes.yAxisLabel && !changes.yAxisLabel.firstChange) + ) { + if (this._data.length === 0) { + this.clearChart(); + this.hideChart(); + } else { + this.clearChart(); + this.showChart(); + this.createChart(); + } + } + } + + ngAfterViewInit(): void { + if (this._data.length === 0) this.hideChart(); + else this.createChart(); + } + + ngOnDestroy(): void { + this.destroyChart(); + } + + clearChart() { + const svg = d3.select(this.chartRef.nativeElement); + svg.selectAll('*').remove(); + } + + hideChart() { + const svg = d3.select(this.chartRef.nativeElement); + svg.style('display', 'none'); + } + + showChart() { + const svg = d3.select(this.chartRef.nativeElement); + svg.style('display', 'block'); + } + + destroyChart() { + if (this.chartInitialized) this.chart.remove(); + if (this.tooltipInitialized) this.tooltip.remove(); + } + + showTooltip(text: string, x: number, y: number): void { + this.tooltip = d3 + .select(this.tooltipRef.nativeElement) + .style('left', `${x}px`) + .style('top', `${y}px`) + .style('display', 'block') + .html(text); + this.tooltipInitialized = true; + } + + hideTooltip() { + if (this.tooltipInitialized) { + this.tooltip.style('display', 'none'); + } + } + + getBarCenterX(tissue: string, xScale: d3.ScaleBand): number { + return (xScale(tissue) || 0) + xScale.bandwidth() / 2; + } + + // get the current width allotted to this chart or default + getChartBoundingWidth(): number { + return ( + d3.select(this.chartRef.nativeElement).node()?.getBoundingClientRect() + .width || this.MIN_CHART_WIDTH + ); + } + + createChart() { + if (this._data.length > 0) { + const barColor = this.helperService.getColor('secondary'); + const width = this.getChartBoundingWidth(); + const height = this.CHART_HEIGHT; + const innerWidth = width - this.chartMargin.left - this.chartMargin.right; + const innerHeight = height - this.chartMargin.top - this.chartMargin.bottom; + + this.chart = d3 + .select(this.chartRef.nativeElement) + .attr('width', width) + .attr('height', height) + .append('g') + .attr('transform', `translate(${this.chartMargin.left}, ${this.chartMargin.top})`); + + // SCALES + this.chartXScale = d3 + .scaleBand() + .domain(this._data.map((d) => d.tissue)) + .range([0, innerWidth]) + .padding(0.2); + + const yScale = d3 + .scaleLinear() + .domain([0, this.maxValueY]) + .nice() + .range([innerHeight, 0]); + + // BARS + this.chartBars = this.chart + .selectAll('.medianbars') + .data(this._data) + .enter() + .append('rect') + .attr('class', 'medianbars') + .attr('x', (d) => this.chartXScale(d.tissue) as number) + .attr('y', (d) => yScale(d.median || 0)) + .attr('width', this.chartXScale.bandwidth()) + .attr('height', (d) => innerHeight - yScale(d.median || 0)) + .attr('fill', barColor); + + // SCORE LABELS + this.chartScoreLabels = this.chart + .selectAll('.bar-labels') + .data(this._data) + .enter() + .append('text') + .attr('class', 'bar-labels') + .attr('x', (d) => this.getBarCenterX(d.tissue, this.chartXScale)) + .attr('y', (d) => yScale(d.median || 0) - 5) + .text((d) => this.helperService.roundNumber(d.median || 0, 2)); + + // X-AXIS + const xAxis = d3.axisBottom(this.chartXScale); + this.chartXAxisDrawn = this.chart + .append('g') + .attr('class', 'x-axis') + .attr('transform', `translate(0, ${innerHeight})`) + .call(xAxis.tickSizeOuter(0)); + this.chartXAxisDrawn + .selectAll('.tick') + .on('mouseenter', (_, tissue) => { + const tooltipText = this.helperService.getGCTColumnTooltipText( + tissue as string + ); + this.showTooltip( + tooltipText, + this.getBarCenterX(tissue as string, this.chartXScale) + this.chartMargin.left, + height - this.chartMargin.top + ); + }) + .on('mouseleave', () => { + this.hideTooltip(); + }); + + // Y-AXIS + const yAxis = d3.axisLeft(yScale); + this.chart.append('g').attr('class', 'y-axis').call(yAxis); + + // X-AXIS LABEL + this.chartXAxisLabel = this.chart + .append('text') + .attr('class', 'x-axis-label') + .attr('x', innerWidth / 2) + .attr('y', innerHeight + this.chartMargin.bottom) + .attr('text-anchor', 'middle') + .text(this.xAxisLabel); + + // Y-AXIS LABEL + this.chart + .append('text') + .attr('class', 'y-axis-label') + .attr('x', -innerHeight / 2) + .attr('y', -this.chartMargin.left) + .attr('dy', '1em') + .attr('text-anchor', 'middle') + .attr('transform', 'rotate(-90)') + .text(this.yAxisLabel); + + // THRESHOLD LINE + if (this.shouldShowThresholdLine) { + this.chartThresholdLine = this.chart + .append('line') + .attr('class', 'meaningful-expression-threshold-line') + .attr('x1', 0) + .attr('x2', innerWidth) + .attr('y1', yScale(this.MEANINGFUL_EXPRESSION_THRESHOLD)) + .attr('y2', yScale(this.MEANINGFUL_EXPRESSION_THRESHOLD)) + .attr('stroke', 'red'); + } + + this.chartInitialized = true; + } + } + + resizeChart = (divSize: number): void => { + // calculate new width + const width = Math.max(divSize, this.MIN_CHART_WIDTH); + const innerWidth = width - this.chartMargin.left - this.chartMargin.right; + + // update chart size + this.chart.attr('width', width); + + // update chartXScale + this.chartXScale.range([0, innerWidth]); + + // update bars + this.chartBars + .transition() + .attr('x', (d) => this.chartXScale(d.tissue) as number) + .attr('width', this.chartXScale.bandwidth()); + + // update score labels + this.chartScoreLabels + .transition() + .attr('x', (d) => this.getBarCenterX(d.tissue, this.chartXScale)); + + // update drawn x-axis + const xAxis = d3.axisBottom(this.chartXScale); + this.chartXAxisDrawn + .transition() + .call(xAxis.tickSizeOuter(0)); + + // update x-axis label + this.chartXAxisLabel + .transition() + .attr('x', innerWidth / 2); + + // update threshold line + if (this.shouldShowThresholdLine) { + this.chartThresholdLine + .transition() + .attr('x2', innerWidth); + } + }; +} diff --git a/src/app/features/charts/components/score-barchart/score-barchart.component.html b/src/app/features/charts/components/score-barchart/score-barchart.component.html index 39d8fadf..6d1a768e 100644 --- a/src/app/features/charts/components/score-barchart/score-barchart.component.html +++ b/src/app/features/charts/components/score-barchart/score-barchart.component.html @@ -1,5 +1,5 @@ -
-
+
+
No data is currently available.
diff --git a/src/app/features/charts/components/score-barchart/score-barchart.component.scss b/src/app/features/charts/components/score-barchart/score-barchart.component.scss index 996bd408..52d195fd 100644 --- a/src/app/features/charts/components/score-barchart/score-barchart.component.scss +++ b/src/app/features/charts/components/score-barchart/score-barchart.component.scss @@ -1,49 +1,4 @@ -$tooltip-color: #63676C; - #score-barchart { - position: relative; - height: 350px; - - svg { - width: 100%; - } - - #score-barchart-tooltip { - position: absolute; - text-align: center; - padding: 5px; - font-size: 14px; - background-color: $tooltip-color; - color: white; - display: none; - z-index: 200; - opacity: 0.9; - width: 200px; - cursor: pointer; - border-radius: 5px; - pointer-events: none; - } - - .tooltip-arrow { - &::before { - content: ''; - position: absolute; - left: 50%; - border: 10px solid transparent; - transform: translateX(-50%); - } - - &.arrow-below { - transform: translate(calc(-50%), calc(-100% - 20px)); - - &::before { - bottom: -9px; - border-bottom: 0; - border-top-color: $tooltip-color; - } - } - } - .negative-bars { &:hover { fill: transparent; @@ -66,27 +21,4 @@ $tooltip-color: #63676C; user-select: none; } } - - .x-axis .tick { - cursor: default; - } - - .y-axis .tick { - cursor: default; - } - - text.x-axis-label { - cursor: default; - } - - text.y-axis-label { - cursor: default; - } - - .chart-no-data { - display: flex; - justify-content: center; - align-items: center; - height: 100%; - } } \ No newline at end of file diff --git a/src/app/features/genes/components/gene-evidence-rna/gene-evidence-rna.component.html b/src/app/features/genes/components/gene-evidence-rna/gene-evidence-rna.component.html index 9d8063ca..b0785dea 100644 --- a/src/app/features/genes/components/gene-evidence-rna/gene-evidence-rna.component.html +++ b/src/app/features/genes/components/gene-evidence-rna/gene-evidence-rna.component.html @@ -40,10 +40,10 @@

- + >
diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index f668e58c..711d0ab4 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -80,6 +80,7 @@ $extra-colors: ( border: map.get($main-colors, 'gray-300'), separator: map.get($main-colors, 'gray-300'), shadow: #c7c5c5, + tooltip: #63676C, ); :root { diff --git a/src/styles/components/_chart_d3.scss b/src/styles/components/_chart_d3.scss new file mode 100644 index 00000000..b26c952f --- /dev/null +++ b/src/styles/components/_chart_d3.scss @@ -0,0 +1,94 @@ +.chart-d3 { + position: relative; + height: 350px; + + svg { + width: 100%; + } + + .bar-labels { + cursor: default; + + &:hover { + cursor: default; + } + } + + // -------------------------------------------------------------------------- // + // EMPTY CHART + // -------------------------------------------------------------------------- // + .chart-no-data { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + + .chart-no-data-text { + font-size: var(--font-size-lg); + font-style: italic; + color: var(--color-gray-600); + } + } + + // -------------------------------------------------------------------------- // + // AXES + // -------------------------------------------------------------------------- // + .y-axis-label, + .x-axis-label { + text-transform: uppercase; + cursor: default; + } + + .x-axis .tick, .y-axis .tick { + cursor: default; + } + + // -------------------------------------------------------------------------- // + // TOOLTIP + // -------------------------------------------------------------------------- // + #tooltip { + position: absolute; + text-align: center; + padding: 5px; + font-size: 14px; + background-color: var(--color-tooltip); + color: white; + display: none; + z-index: 200; + opacity: 0.9; + width: 200px; + cursor: pointer; + border-radius: 5px; + pointer-events: none; + } + + .tooltip-arrow { + &::before { + content: ''; + position: absolute; + left: 50%; + border: 10px solid transparent; + transform: translateX(-50%); + } + + &.arrow-above { + transform: translate(calc(-50%), calc(50% - 12px)); + + &::before { + top: -9px; + border-top: 0; + border-bottom-color: var(--color-tooltip); + } + } + + &.arrow-below { + transform: translate(calc(-50%), calc(-100% - 20px)); + + &::before { + bottom: -9px; + border-bottom: 0; + border-top-color: var(--color-tooltip); + } + } + } +} diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 8479b2e6..a6aa2e51 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -22,6 +22,7 @@ @import 'components/tooltip'; @import 'components/table'; @import 'components/chart'; +@import 'components/chart_d3'; @import 'components/icon'; @import 'components/shame';