From 10cdbf0a24053a1f0fca0dac6fa7597c9ad1cf40 Mon Sep 17 00:00:00 2001 From: Hallie Swan <26949006+hallieswan@users.noreply.github.com> Date: Fri, 16 Jun 2023 14:51:43 -0700 Subject: [PATCH 1/5] refactor median-chart to remove dc.js dependency, move styles shared between median-barchart and score-barchart into global stylesheet --- src/app/features/charts/charts.module.ts | 3 + src/app/features/charts/components/index.ts | 1 + .../components/median-barchart/index.ts | 1 + .../median-barchart.component.html | 12 + .../median-barchart.component.scss | 26 ++ .../median-barchart.component.spec.ts | 222 +++++++++++++++ .../median-barchart.component.ts | 260 ++++++++++++++++++ .../score-barchart.component.html | 4 +- .../score-barchart.component.scss | 76 +---- .../gene-evidence-rna.component.html | 4 +- src/styles/_variables.scss | 1 + src/styles/components/_chart_d3.scss | 94 +++++++ src/styles/styles.scss | 1 + 13 files changed, 626 insertions(+), 79 deletions(-) create mode 100644 src/app/features/charts/components/median-barchart/index.ts create mode 100644 src/app/features/charts/components/median-barchart/median-barchart.component.html create mode 100644 src/app/features/charts/components/median-barchart/median-barchart.component.scss create mode 100644 src/app/features/charts/components/median-barchart/median-barchart.component.spec.ts create mode 100644 src/app/features/charts/components/median-barchart/median-barchart.component.ts create mode 100644 src/styles/components/_chart_d3.scss diff --git a/src/app/features/charts/charts.module.ts b/src/app/features/charts/charts.module.ts index 1f1b2b028..c00cb82de 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 189d12e46..9d00708b7 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 000000000..a587af3d3 --- /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 000000000..b8797ac54 --- /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 000000000..530405ea6 --- /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 000000000..c3a745699 --- /dev/null +++ b/src/app/features/charts/components/median-barchart/median-barchart.component.spec.ts @@ -0,0 +1,222 @@ +// -------------------------------------------------------------------------- // +// 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.medianexpression; +const TISSUES = ['TCX', 'PHG', 'STG']; +const SMALL_MOCK_DATA = [ + { + ensembl_gene_id: 'MOCK_GENE', + minimumlogcpm: -2.54173091337051, + quartile1logcpm: -1.03358030635935, + medianlogcpm: 0.483801733963266, + meanlogcpm: -0.517398199667356, + quartile3logcpm: -0.0800759845652829, + maximumlogcpm: 2.32290808871289, + tissue: TISSUES[0], + }, + { + ensembl_gene_id: 'MOCK_GENE', + minimumlogcpm: -2.44077907413711, + quartile1logcpm: -0.592671867557559, + medianlogcpm: 0.013739530502129, + meanlogcpm: -0.0324143336865, + quartile3logcpm: 0.49577213202412, + maximumlogcpm: 2.23019575245731, + tissue: TISSUES[1], + }, + { + ensembl_gene_id: 'MOCK_GENE', + minimumlogcpm: -5.03189866356294, + quartile1logcpm: -1.02644563959975, + medianlogcpm: 0.176348063122062, + meanlogcpm: -0.323038107200895, + quartile3logcpm: 0.391874711168331, + maximumlogcpm: 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, medianlogcpm: -1 * obj.medianlogcpm }; + }) + ); + + 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], medianlogcpm: -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', () => { + const { chart } = setUp(); + + expect( + chart?.querySelector('.meaningful-expression-threshold-line') + ).toBeTruthy(); + }); + + it('should render the meaningful expression threshold even when all values are small', () => { + const { chart } = setUp(SMALL_MOCK_DATA); + + expect( + chart?.querySelector('.meaningful-expression-threshold-line') + ).toBeTruthy(); + }); + + 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 000000000..efd50bf7c --- /dev/null +++ b/src/app/features/charts/components/median-barchart/median-barchart.component.ts @@ -0,0 +1,260 @@ +// -------------------------------------------------------------------------- // +// 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; + + get data() { + return this._data; + } + @Input() set data(data: MedianExpression[]) { + this._data = data + .filter((el) => el.medianlogcpm && el.medianlogcpm > 0) + .sort((a, b) => a.tissue.localeCompare(b.tissue)); + this.maxValueY = Math.max( + this.MEANINGFUL_EXPRESSION_THRESHOLD, + d3.max(this._data, (d) => d.medianlogcpm) || 0 + ); + } + + @Input() xAxisLabel = ''; + @Input() yAxisLabel = 'LOG2 CPM'; + + @ViewChild('chart') chartRef: ElementRef = {} as ElementRef; + @ViewChild('tooltip') tooltipRef: ElementRef = {} as ElementRef; + + dimension: any; + group: any; + + constructor(private helperService: HelperService) {} + + 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 || 500 + ); + } + + createChart() { + if (this._data.length > 0) { + const barColor = this.helperService.getColor('secondary'); + const width = this.getChartBoundingWidth(); + const height = 350; + const margin = { top: 20, right: 20, bottom: 65, left: 65 }; + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + this.chart = d3 + .select(this.chartRef.nativeElement) + .attr('width', width) + .attr('height', height) + .append('g') + .attr('transform', `translate(${margin.left}, ${margin.top})`); + + // SCALES + const xScale = 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.chart + .selectAll('.medianbars') + .data(this._data) + .enter() + .append('rect') + .attr('class', 'medianbars') + .attr('x', (d) => xScale(d.tissue) as number) + .attr('y', (d) => yScale(d.medianlogcpm || 0)) + .attr('width', xScale.bandwidth()) + .attr('height', (d) => innerHeight - yScale(d.medianlogcpm || 0)) + .attr('fill', barColor); + + // SCORE LABELS + this.chart + .selectAll('.bar-labels') + .data(this._data) + .enter() + .append('text') + .attr('class', 'bar-labels') + .attr('x', (d) => this.getBarCenterX(d.tissue, xScale)) + .attr('y', (d) => yScale(d.medianlogcpm || 0) - 5) + .text((d) => this.helperService.roundNumber(d.medianlogcpm || 0, 2)); + + // X-AXIS + const xAxis = d3.axisBottom(xScale); + this.chart + .append('g') + .attr('class', 'x-axis') + .attr('transform', `translate(0, ${innerHeight})`) + .call(xAxis.tickSizeOuter(0)) + .selectAll('.tick') + .on('mouseenter', (_, tissue) => { + const tooltipText = this.helperService.getGCTColumnTooltipText( + tissue as string + ); + this.showTooltip( + tooltipText, + this.getBarCenterX(tissue as string, xScale) + margin.left, + height - margin.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.chart + .append('text') + .attr('class', 'x-axis-label') + .attr('x', innerWidth / 2) + .attr('y', innerHeight + margin.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', -margin.left) + .attr('dy', '1em') + .attr('text-anchor', 'middle') + .attr('transform', 'rotate(-90)') + .text(this.yAxisLabel); + + // THRESHOLD LINE + 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; + } + } + + @HostListener('window:resize', ['$event']) + onResize() { + this.clearChart(); + this.createChart(); + } +} 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 39d8fadfe..6d1a768e0 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 8430a57ac..82720761e 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; @@ -51,38 +6,9 @@ $tooltip-color: #63676C; } } - .scorebars { - &:hover { - cursor: pointer; - } - } - - .bar-labels { + .scorebars, .bar-labels { &:hover { cursor: pointer; } } - - .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 9d91365f5..290b38485 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 f668e58ce..711d0ab45 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 000000000..e04684689 --- /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%)); + + &::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 8479b2e65..a6aa2e512 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'; From 2096feb70ee9f24ce5c58ea1af5dff0c66383939 Mon Sep 17 00:00:00 2001 From: Hallie Swan <26949006+hallieswan@users.noreply.github.com> Date: Fri, 16 Jun 2023 16:17:10 -0700 Subject: [PATCH 2/5] fix lint error --- .../components/median-barchart/median-barchart.component.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index efd50bf7c..63689211b 100644 --- a/src/app/features/charts/components/median-barchart/median-barchart.component.ts +++ b/src/app/features/charts/components/median-barchart/median-barchart.component.ts @@ -30,8 +30,7 @@ import { HelperService } from '../../../../core/services'; styleUrls: ['./median-barchart.component.scss'], encapsulation: ViewEncapsulation.None, }) -export class MedianBarChartComponent - implements OnChanges, AfterViewInit, OnDestroy +export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDestroy { private chartInitialized = false; private tooltipInitialized = false; From 832c530f417b6bab568dbb2b217d37a6dbdb4826 Mon Sep 17 00:00:00 2001 From: Hallie Swan <26949006+hallieswan@users.noreply.github.com> Date: Fri, 7 Jul 2023 14:26:14 -0700 Subject: [PATCH 3/5] updates per PR and add transition on resize --- .../median-barchart.component.html | 4 +- .../median-barchart.component.ts | 114 +++++++++++++----- src/styles/components/_chart_d3.scss | 2 +- 3 files changed, 88 insertions(+), 32 deletions(-) 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 index b8797ac54..cdccce2d9 100644 --- a/src/app/features/charts/components/median-barchart/median-barchart.component.html +++ b/src/app/features/charts/components/median-barchart/median-barchart.component.html @@ -1,11 +1,11 @@ -
+
- +
No data is currently available.
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 index 63689211b..83433fb26 100644 --- a/src/app/features/charts/components/median-barchart/median-barchart.component.ts +++ b/src/app/features/charts/components/median-barchart/median-barchart.component.ts @@ -40,6 +40,18 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest 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 resizeTimer: ReturnType | number = 0; + get data() { return this._data; } @@ -53,17 +65,28 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest ); } + @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; - dimension: any; - group: any; - 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) || @@ -134,7 +157,7 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest getChartBoundingWidth(): number { return ( d3.select(this.chartRef.nativeElement).node()?.getBoundingClientRect() - .width || 500 + .width || this.MIN_CHART_WIDTH ); } @@ -142,20 +165,19 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest if (this._data.length > 0) { const barColor = this.helperService.getColor('secondary'); const width = this.getChartBoundingWidth(); - const height = 350; - const margin = { top: 20, right: 20, bottom: 65, left: 65 }; - const innerWidth = width - margin.left - margin.right; - const innerHeight = height - margin.top - margin.bottom; + 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(${margin.left}, ${margin.top})`); + .attr('transform', `translate(${this.chartMargin.left}, ${this.chartMargin.top})`); // SCALES - const xScale = d3 + this.chartXScale = d3 .scaleBand() .domain(this._data.map((d) => d.tissue)) .range([0, innerWidth]) @@ -168,36 +190,37 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest .range([innerHeight, 0]); // BARS - this.chart + this.chartBars = this.chart .selectAll('.medianbars') .data(this._data) .enter() .append('rect') .attr('class', 'medianbars') - .attr('x', (d) => xScale(d.tissue) as number) + .attr('x', (d) => this.chartXScale(d.tissue) as number) .attr('y', (d) => yScale(d.medianlogcpm || 0)) - .attr('width', xScale.bandwidth()) + .attr('width', this.chartXScale.bandwidth()) .attr('height', (d) => innerHeight - yScale(d.medianlogcpm || 0)) .attr('fill', barColor); // SCORE LABELS - this.chart + this.chartScoreLabels = this.chart .selectAll('.bar-labels') .data(this._data) .enter() .append('text') .attr('class', 'bar-labels') - .attr('x', (d) => this.getBarCenterX(d.tissue, xScale)) + .attr('x', (d) => this.getBarCenterX(d.tissue, this.chartXScale)) .attr('y', (d) => yScale(d.medianlogcpm || 0) - 5) .text((d) => this.helperService.roundNumber(d.medianlogcpm || 0, 2)); // X-AXIS - const xAxis = d3.axisBottom(xScale); - this.chart + 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)) + .call(xAxis.tickSizeOuter(0)); + this.chartXAxisDrawn .selectAll('.tick') .on('mouseenter', (_, tissue) => { const tooltipText = this.helperService.getGCTColumnTooltipText( @@ -205,8 +228,8 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest ); this.showTooltip( tooltipText, - this.getBarCenterX(tissue as string, xScale) + margin.left, - height - margin.top + this.getBarCenterX(tissue as string, this.chartXScale) + this.chartMargin.left, + height - this.chartMargin.top ); }) .on('mouseleave', () => { @@ -218,11 +241,11 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest this.chart.append('g').attr('class', 'y-axis').call(yAxis); // X-AXIS LABEL - this.chart + this.chartXAxisLabel = this.chart .append('text') .attr('class', 'x-axis-label') .attr('x', innerWidth / 2) - .attr('y', innerHeight + margin.bottom) + .attr('y', innerHeight + this.chartMargin.bottom) .attr('text-anchor', 'middle') .text(this.xAxisLabel); @@ -231,14 +254,14 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest .append('text') .attr('class', 'y-axis-label') .attr('x', -innerHeight / 2) - .attr('y', -margin.left) + .attr('y', -this.chartMargin.left) .attr('dy', '1em') .attr('text-anchor', 'middle') .attr('transform', 'rotate(-90)') .text(this.yAxisLabel); // THRESHOLD LINE - this.chart + this.chartThresholdLine = this.chart .append('line') .attr('class', 'meaningful-expression-threshold-line') .attr('x1', 0) @@ -251,9 +274,42 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest } } - @HostListener('window:resize', ['$event']) - onResize() { - this.clearChart(); - this.createChart(); - } + 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 + this.chartThresholdLine + .transition() + .attr('x2', innerWidth); + }; } diff --git a/src/styles/components/_chart_d3.scss b/src/styles/components/_chart_d3.scss index e04684689..b26c952ff 100644 --- a/src/styles/components/_chart_d3.scss +++ b/src/styles/components/_chart_d3.scss @@ -72,7 +72,7 @@ } &.arrow-above { - transform: translate(calc(-50%), calc(50%)); + transform: translate(calc(-50%), calc(50% - 12px)); &::before { top: -9px; From 8129a6deeea66d43249ac53df898cd552cd6ce6d Mon Sep 17 00:00:00 2001 From: Hallie Swan <26949006+hallieswan@users.noreply.github.com> Date: Mon, 10 Jul 2023 16:14:58 -0700 Subject: [PATCH 4/5] match current chart behavior which does not show meaningful expression threshold line when values are small --- .../median-barchart.component.spec.ts | 6 ++-- .../median-barchart.component.ts | 33 ++++++++++--------- 2 files changed, 21 insertions(+), 18 deletions(-) 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 index c3a745699..40c6b9fc4 100644 --- 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 @@ -167,7 +167,7 @@ describe('Component: BarChart - Median', () => { ); }); - it('should render the meaningful expression threshold', () => { + it('should render the meaningful expression threshold when values are larger than threshold', () => { const { chart } = setUp(); expect( @@ -175,12 +175,12 @@ describe('Component: BarChart - Median', () => { ).toBeTruthy(); }); - it('should render the meaningful expression threshold even when all values are small', () => { + 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') - ).toBeTruthy(); + ).toBeFalsy(); }); it('should alphabetize the x-axis values', () => { 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 index 83433fb26..b996c7b29 100644 --- a/src/app/features/charts/components/median-barchart/median-barchart.component.ts +++ b/src/app/features/charts/components/median-barchart/median-barchart.component.ts @@ -49,6 +49,7 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest private chartBars!: d3.Selection; private chartScoreLabels!: d3.Selection; private chartThresholdLine!: d3.Selection; + private shouldShowThresholdLine = false; private resizeTimer: ReturnType | number = 0; @@ -59,10 +60,8 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest this._data = data .filter((el) => el.medianlogcpm && el.medianlogcpm > 0) .sort((a, b) => a.tissue.localeCompare(b.tissue)); - this.maxValueY = Math.max( - this.MEANINGFUL_EXPRESSION_THRESHOLD, - d3.max(this._data, (d) => d.medianlogcpm) || 0 - ); + this.maxValueY = d3.max(this._data, (d) => d.medianlogcpm) || 0; + this.shouldShowThresholdLine = this.MEANINGFUL_EXPRESSION_THRESHOLD <= this.maxValueY; } @Input() shouldResize = true; @@ -261,14 +260,16 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest .text(this.yAxisLabel); // THRESHOLD LINE - 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'); + 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; } @@ -308,8 +309,10 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest .attr('x', innerWidth / 2); // update threshold line - this.chartThresholdLine - .transition() - .attr('x2', innerWidth); + if (this.shouldShowThresholdLine) { + this.chartThresholdLine + .transition() + .attr('x2', innerWidth); + } }; } From 8022bb9e87ea9e67e0c13504c3e305c0bb1f5138 Mon Sep 17 00:00:00 2001 From: Hallie Swan <26949006+hallieswan@users.noreply.github.com> Date: Mon, 12 Feb 2024 15:36:10 -0800 Subject: [PATCH 5/5] AG-300: update json keys --- .../median-barchart.component.spec.ts | 45 +++++++++---------- .../median-barchart.component.ts | 12 ++--- 2 files changed, 27 insertions(+), 30 deletions(-) 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 index 40c6b9fc4..6b73bc012 100644 --- 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 @@ -17,37 +17,34 @@ import { MedianExpression } from '../../../../models/genes'; // -------------------------------------------------------------------------- // const XAXIS_LABEL = 'BRAIN REGION'; const YAXIS_LABEL = 'LOG2CPM'; -const FULL_MOCK_DATA = geneMock1.medianexpression; +const FULL_MOCK_DATA = geneMock1.median_expression; const TISSUES = ['TCX', 'PHG', 'STG']; const SMALL_MOCK_DATA = [ { - ensembl_gene_id: 'MOCK_GENE', - minimumlogcpm: -2.54173091337051, - quartile1logcpm: -1.03358030635935, - medianlogcpm: 0.483801733963266, - meanlogcpm: -0.517398199667356, - quartile3logcpm: -0.0800759845652829, - maximumlogcpm: 2.32290808871289, + min: -2.54173091337051, + first_quartile: -1.03358030635935, + median: 0.483801733963266, + mean: -0.517398199667356, + third_quartile: -0.0800759845652829, + max: 2.32290808871289, tissue: TISSUES[0], }, { - ensembl_gene_id: 'MOCK_GENE', - minimumlogcpm: -2.44077907413711, - quartile1logcpm: -0.592671867557559, - medianlogcpm: 0.013739530502129, - meanlogcpm: -0.0324143336865, - quartile3logcpm: 0.49577213202412, - maximumlogcpm: 2.23019575245731, + min: -2.44077907413711, + first_quartile: -0.592671867557559, + median: 0.013739530502129, + mean: -0.0324143336865, + third_quartile: 0.49577213202412, + max: 2.23019575245731, tissue: TISSUES[1], }, { - ensembl_gene_id: 'MOCK_GENE', - minimumlogcpm: -5.03189866356294, - quartile1logcpm: -1.02644563959975, - medianlogcpm: 0.176348063122062, - meanlogcpm: -0.323038107200895, - quartile3logcpm: 0.391874711168331, - maximumlogcpm: 1.9113258251877, + min: -5.03189866356294, + first_quartile: -1.02644563959975, + median: 0.176348063122062, + mean: -0.323038107200895, + third_quartile: 0.391874711168331, + max: 1.9113258251877, tissue: TISSUES[2], }, ]; @@ -102,7 +99,7 @@ describe('Component: BarChart - Median', () => { it('should not render the chart if all values are negative', () => { const { chart } = setUp( SMALL_MOCK_DATA.map((obj) => { - return { ...obj, medianlogcpm: -1 * obj.medianlogcpm }; + return { ...obj, median: -1 * obj.median }; }) ); @@ -139,7 +136,7 @@ describe('Component: BarChart - Median', () => { it('should not render bars for negative values', () => { const { chart } = setUp([ - { ...SMALL_MOCK_DATA[0], medianlogcpm: -0.01 }, + { ...SMALL_MOCK_DATA[0], median: -0.01 }, ...SMALL_MOCK_DATA.slice(1), ]); 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 index b996c7b29..810ac5416 100644 --- a/src/app/features/charts/components/median-barchart/median-barchart.component.ts +++ b/src/app/features/charts/components/median-barchart/median-barchart.component.ts @@ -58,9 +58,9 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest } @Input() set data(data: MedianExpression[]) { this._data = data - .filter((el) => el.medianlogcpm && el.medianlogcpm > 0) + .filter((el) => el.median && el.median > 0) .sort((a, b) => a.tissue.localeCompare(b.tissue)); - this.maxValueY = d3.max(this._data, (d) => d.medianlogcpm) || 0; + this.maxValueY = d3.max(this._data, (d) => d.median) || 0; this.shouldShowThresholdLine = this.MEANINGFUL_EXPRESSION_THRESHOLD <= this.maxValueY; } @@ -196,9 +196,9 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest .append('rect') .attr('class', 'medianbars') .attr('x', (d) => this.chartXScale(d.tissue) as number) - .attr('y', (d) => yScale(d.medianlogcpm || 0)) + .attr('y', (d) => yScale(d.median || 0)) .attr('width', this.chartXScale.bandwidth()) - .attr('height', (d) => innerHeight - yScale(d.medianlogcpm || 0)) + .attr('height', (d) => innerHeight - yScale(d.median || 0)) .attr('fill', barColor); // SCORE LABELS @@ -209,8 +209,8 @@ export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDest .append('text') .attr('class', 'bar-labels') .attr('x', (d) => this.getBarCenterX(d.tissue, this.chartXScale)) - .attr('y', (d) => yScale(d.medianlogcpm || 0) - 5) - .text((d) => this.helperService.roundNumber(d.medianlogcpm || 0, 2)); + .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);