diff --git a/frontend/src/app/longitudinal/longitudinal.component.spec.ts b/frontend/src/app/longitudinal/longitudinal.component.spec.ts index cd3a53b..8bdf6df 100644 --- a/frontend/src/app/longitudinal/longitudinal.component.spec.ts +++ b/frontend/src/app/longitudinal/longitudinal.component.spec.ts @@ -43,9 +43,22 @@ describe('LongitudinalComponent', () => { httpMock.verify(); }); - it('should create', () => { + it('should create', async () => { fixture.detectChanges(); + const reqMappings = httpMock.expectOne( + './assets/lower_to_original_case.json' + ); + reqMappings.flush({ + 'feature name': 'Feature Name', + 'another feature': 'Another Feature', + }); + + // Wait for the ngOnInit to complete loading the mappings + await fixture.whenStable(); + const reqTables = httpMock.expectOne(`${environment.API_URL}/longitudinal`); + reqTables.flush(['longitudinal_table1', 'longitudinal_table2']); + // Handle the HTTP requests triggered by ngOnInit const reqMeta = httpMock.expectOne( `${environment.API_URL}/cohorts/metadata` @@ -77,21 +90,10 @@ describe('LongitudinalComponent', () => { }, }); - const reqTables = httpMock.expectOne(`${environment.API_URL}/longitudinal`); - reqTables.flush(['longitudinal_table1', 'longitudinal_table2']); - - const reqMappings = httpMock.expectOne( - './assets/lower_to_original_case.json' - ); - reqMappings.flush({ - 'feature name': 'Feature Name', - 'another feature': 'Another Feature', - }); - expect(component).toBeTruthy(); }); - it('should fetch colors on init', () => { + it('should fetch colors on init', async () => { const mockMetadata: Metadata = { cohort1: { Participants: 100, @@ -121,14 +123,6 @@ describe('LongitudinalComponent', () => { fixture.detectChanges(); - const reqMeta = httpMock.expectOne( - `${environment.API_URL}/cohorts/metadata` - ); - reqMeta.flush(mockMetadata); - - const reqTables = httpMock.expectOne(`${environment.API_URL}/longitudinal`); - reqTables.flush(['longitudinal_table1', 'longitudinal_table2']); - const reqMappings = httpMock.expectOne( './assets/lower_to_original_case.json' ); @@ -136,6 +130,16 @@ describe('LongitudinalComponent', () => { 'feature name': 'Feature Name', 'another feature': 'Another Feature', }); + // Wait for the ngOnInit to complete loading the mappings + await fixture.whenStable(); + + const reqTables = httpMock.expectOne(`${environment.API_URL}/longitudinal`); + reqTables.flush(['longitudinal_table1', 'longitudinal_table2']); + + const reqMeta = httpMock.expectOne( + `${environment.API_URL}/cohorts/metadata` + ); + reqMeta.flush(mockMetadata); expect(component.colors).toEqual({ cohort1: '#ff0000', @@ -143,11 +147,21 @@ describe('LongitudinalComponent', () => { }); }); - it('should fetch longitudinal tables on init', () => { + it('should fetch longitudinal tables on init', async () => { const mockTables = ['longitudinal_table1', 'longitudinal_table2']; fixture.detectChanges(); + const reqMappings = httpMock.expectOne( + './assets/lower_to_original_case.json' + ); + reqMappings.flush({ + 'feature name': 'Feature Name', + 'another feature': 'Another Feature', + }); + // Wait for the ngOnInit to complete loading the mappings + await fixture.whenStable(); + const reqMeta = httpMock.expectOne( `${environment.API_URL}/cohorts/metadata` ); @@ -182,14 +196,6 @@ describe('LongitudinalComponent', () => { expect(reqTables.request.method).toBe('GET'); reqTables.flush(mockTables); - const reqMappings = httpMock.expectOne( - './assets/lower_to_original_case.json' - ); - reqMappings.flush({ - 'feature name': 'Feature Name', - 'another feature': 'Another Feature', - }); - expect(component.longitudinalTables).toEqual(['Table1', 'Table2']); }); diff --git a/frontend/src/app/longitudinal/longitudinal.component.ts b/frontend/src/app/longitudinal/longitudinal.component.ts index 6890152..0974143 100644 --- a/frontend/src/app/longitudinal/longitudinal.component.ts +++ b/frontend/src/app/longitudinal/longitudinal.component.ts @@ -132,22 +132,27 @@ export class LongitudinalComponent implements OnInit, OnDestroy { ); } - loadOriginalCaseMappings(): void { - this.http - .get<{ [key: string]: string }>('./assets/lower_to_original_case.json') - .subscribe({ - next: (data) => { - this.originalVariableNameMappings = data; - console.info( - 'Lowercase to original case mappings successfully loaded' - ); - }, - error: (e) => - console.error( - 'Error loading lowercase to original case mappings:', - e - ), - }); + loadOriginalCaseMappings(): Promise { + return new Promise((resolve, reject) => { + this.http + .get<{ [key: string]: string }>('./assets/lower_to_original_case.json') + .subscribe({ + next: (data) => { + this.originalVariableNameMappings = data; + console.info( + 'Lowercase to original case mappings successfully loaded' + ); + resolve(); + }, + error: (e) => { + console.error( + 'Error loading lowercase to original case mappings:', + e + ); + reject(e); + }, + }); + }); } ngOnDestroy(): void { @@ -155,13 +160,14 @@ export class LongitudinalComponent implements OnInit, OnDestroy { } ngOnInit() { - this.loadOriginalCaseMappings(); - this.fetchLongitudinalTables(); - this.fetchColors(); - this.filteredFeatures = this.featureCtrl.valueChanges.pipe( - startWith(''), - map((value) => this._filterTableName(value || '')) - ); + this.loadOriginalCaseMappings().then(() => { + this.fetchLongitudinalTables(); + this.fetchColors(); + this.filteredFeatures = this.featureCtrl.valueChanges.pipe( + startWith(''), + map((value) => this._filterTableName(value || '')) + ); + }); } removeFeature(): void { diff --git a/frontend/src/app/plot-longitudinal/plot-longitudinal.component.html b/frontend/src/app/plot-longitudinal/plot-longitudinal.component.html index 1446a66..9cb2d3d 100644 --- a/frontend/src/app/plot-longitudinal/plot-longitudinal.component.html +++ b/frontend/src/app/plot-longitudinal/plot-longitudinal.component.html @@ -1,5 +1,5 @@
-

Biomarker-specific Follow-up

+

Variable-specific Follow-up

diff --git a/frontend/src/app/plot-longitudinal/plot-longitudinal.component.ts b/frontend/src/app/plot-longitudinal/plot-longitudinal.component.ts index 35cc064..ada10f2 100644 --- a/frontend/src/app/plot-longitudinal/plot-longitudinal.component.ts +++ b/frontend/src/app/plot-longitudinal/plot-longitudinal.component.ts @@ -21,9 +21,9 @@ import { LineplotService } from '../services/lineplot.service'; }) export class PlotLongitudinalComponent implements OnInit, OnDestroy { cohort: string = ''; - features: string[] = []; + variables: string[] = []; data: LongitudinalData[] = []; - originalData: LongitudinalData[] = []; + originalVariableNameMappings: { [key: string]: string } = {}; private API_URL = environment.API_URL; @ViewChild('lineplot') private chartContainer!: ElementRef; private subscriptions: Subscription[] = []; @@ -58,11 +58,17 @@ export class PlotLongitudinalComponent implements OnInit, OnDestroy { } generateLineplot(): void { - const features = []; - for (const feature of this.features) { - features.push(this._transformLongitudinalName(feature)); + const variables = []; + for (const variable of this.variables) { + variables.push(this._transformLongitudinalName(variable)); } - const features_string = features.map((item) => `${item}`).join(', '); + const features_string = + variables.length > 1 + ? variables.slice(0, -1).join(', ') + + ' and ' + + variables[variables.length - 1] + : variables[0] || ''; // Handle single or empty features case + const title = `Longitudinal follow-ups for ${features_string} in the ${this.cohort} cohort`; this.lineplotService.createLineplot( this.chartContainer, @@ -72,28 +78,54 @@ export class PlotLongitudinalComponent implements OnInit, OnDestroy { ); } + loadOriginalCaseMappings(): Promise { + return new Promise((resolve, reject) => { + this.http + .get<{ [key: string]: string }>('./assets/lower_to_original_case.json') + .subscribe({ + next: (data) => { + this.originalVariableNameMappings = data; + console.info( + 'Lowercase to original case mappings successfully loaded' + ); + resolve(); + }, + error: (e) => { + console.error( + 'Error loading lowercase to original case mappings:', + e + ); + reject(e); + }, + }); + }); + } + ngOnDestroy(): void { this.subscriptions.forEach((sub) => sub.unsubscribe()); } ngOnInit(): void { - const sub = this.route.queryParams.subscribe((params) => { - this.cohort = params['cohort'] || ''; - this.features = params['features'] || []; - }); - this.subscriptions.push(sub); + this.loadOriginalCaseMappings().then(() => { + const sub = this.route.queryParams.subscribe((params) => { + this.cohort = params['cohort'] || ''; + this.variables = params['features'] || []; + }); + this.subscriptions.push(sub); - // Ensure features is an array - if (!Array.isArray(this.features)) { - this.features = [this.features]; - } + // Ensure features is an array + if (!Array.isArray(this.variables)) { + this.variables = [this.variables]; + } - // Set the count of features to fetch data - this.dataFetchCount = this.features.length; + // Set the count of features to fetch data + this.dataFetchCount = this.variables.length; - for (const feature of this.features) { - this.fetchLongitudinalTable(feature); - } + // Fetch data for each variable + for (const variable of this.variables) { + this.fetchLongitudinalTable(variable); + } + }); } private _transformLongitudinalName(longitudinal: string): string { @@ -101,6 +133,9 @@ export class PlotLongitudinalComponent implements OnInit, OnDestroy { longitudinal = longitudinal.substring(13); } longitudinal = longitudinal.split('_').join(' '); - return longitudinal.charAt(0).toUpperCase() + longitudinal.slice(1); + const mappedValue = this.originalVariableNameMappings[longitudinal]; + return mappedValue + ? mappedValue + : longitudinal.charAt(0).toUpperCase() + longitudinal.slice(1); } } diff --git a/frontend/src/app/services/lineplot.service.ts b/frontend/src/app/services/lineplot.service.ts index 6e9ace9..c0a0bcc 100644 --- a/frontend/src/app/services/lineplot.service.ts +++ b/frontend/src/app/services/lineplot.service.ts @@ -1,4 +1,5 @@ -import { ElementRef, Injectable } from '@angular/core'; +import { ElementRef, Injectable, Inject, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; import * as d3 from 'd3'; import { LongitudinalData } from '../interfaces/longitudinal-data'; @@ -6,7 +7,7 @@ import { LongitudinalData } from '../interfaces/longitudinal-data'; providedIn: 'root', }) export class LineplotService { - constructor() {} + constructor(@Inject(PLATFORM_ID) private platformId: Object) {} createLineplot( element: ElementRef, @@ -14,6 +15,12 @@ export class LineplotService { colors: { [key: string]: string } = {}, // Default parameter title: string = '' // New optional title parameter ): void { + if (!isPlatformBrowser(this.platformId)) { + console.warn( + `D3 code skipped because it is running on the ${this.platformId}` + ); + return; + } const margin = { top: 40, right: 150, bottom: 60, left: 60 }; // Increase top margin for the title const width = 1200 - margin.left - margin.right; const height = 500 - margin.top - margin.bottom; @@ -33,23 +40,22 @@ export class LineplotService { // Set the domains for the scales x.domain(d3.extent(data, (d) => d.Months) as [number, number]); - y.domain([0, 100]); // Y-axis from 0 to 100 percent + y.domain([0, 100]); - // Add the title - if (title) { - svg - .append('text') - .attr('x', width / 2) - .attr('y', -margin.top / 2) - .attr('text-anchor', 'middle') - .attr('class', 'title') - .text(title); - } + // Determine the maximum value of x (Months) + const maxMonths = Math.ceil(d3.max(data, (d) => d.Months) || 0); - svg - .append('g') - .attr('transform', `translate(0,${height})`) - .call(d3.axisBottom(x)); + // Generate tick values at intervals of 6 + const tickValues = Array.from( + { length: Math.floor(maxMonths / 6) + 1 }, + (_, i) => i * 6 + ).filter((tick) => tick <= maxMonths); + + // Create and configure the x-axis + const xAxis = d3.axisBottom(x).tickValues(tickValues); + + // Append the x-axis to the SVG + svg.append('g').attr('transform', `translate(0,${height})`).call(xAxis); svg.append('g').call(d3.axisLeft(y).ticks(10)); @@ -77,7 +83,7 @@ export class LineplotService { .datum(values) .attr('class', 'line-path') .attr('fill', 'none') - .attr('stroke', cohortColor) // Use the color from the colors object or the color scale + .attr('stroke', cohortColor) .attr('stroke-width', 1.5) .attr('d', line); }); @@ -271,5 +277,13 @@ export class LineplotService { `translate(${width / 2}, ${height + margin.bottom / 1.5})` ) .text('Months'); + + // Add the title + svg + .append('text') + .attr('x', width / 2) + .attr('y', -margin.top / 2) + .attr('text-anchor', 'middle') + .text(title); } }