Skip to content

Commit

Permalink
Support displaying non-numerical observation results (#453)
Browse files Browse the repository at this point in the history
* Add new datatype models and specs

* Update datatypes/coding-model

* Add new factories and update observation-r4-factory

* Update observation model to use new datatypes

* Add new observation-visualization and observation-table components

* Update observation-bar-chart

* Update observation and report-labs-observation components

* Forgot to switch to observation-visualization; add test and update test imports to fix errors
  • Loading branch information
jean-the-coder committed Mar 29, 2024
1 parent 1bcf4aa commit 6264415
Show file tree
Hide file tree
Showing 45 changed files with 1,463 additions and 342 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ObservationBarChartComponent } from './observation-bar-chart.component';
import { ObservationModel } from 'src/lib/models/resources/observation-model';
import { observationR4Factory } from 'src/lib/fixtures/factories/r4/resources/observation-r4-factory';
import { fhirVersions } from 'src/lib/models/constants';

describe('ObservationBarChartComponent', () => {
let component: ObservationBarChartComponent;
Expand All @@ -13,10 +16,65 @@ describe('ObservationBarChartComponent', () => {

fixture = TestBed.createComponent(ObservationBarChartComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});

describe('updateNullMax', () => {
it('updates the second value to the max if and only if the first value is present and the second is falsey', () => {
let test = [
[5, null],
[5, 0],
[5, undefined],
[0, 0],
[4, 6]
]
let expected = [
[5, 8],
[5, 8],
[5, 8],
[0, 0],
[4, 6]
]

expect(component['updateNullMax'](test, 8)).toEqual(expected);
});
});

describe('extractReferenceRange', () => {
it('returns the correct value when there is no reference range', () => {
let observation = new ObservationModel(observationR4Factory.build(), fhirVersions.R4);

expect(component['extractReferenceRange'](observation)).toEqual([0, 0])
});

it('returns the correct value when there is a reference range', () => {
let observation = new ObservationModel(observationR4Factory.referenceRange(5, 10).build(), fhirVersions.R4);
let observation2 = new ObservationModel(observationR4Factory.referenceRangeOnlyHigh(10).build(), fhirVersions.R4);
let observation3 = new ObservationModel(observationR4Factory.referenceRangeOnlyLow(5).build(), fhirVersions.R4);

expect(component['extractReferenceRange'](observation)).toEqual([5, 10])
expect(component['extractReferenceRange'](observation2)).toEqual([0, 10])
expect(component['extractReferenceRange'](observation3)).toEqual([5, 0])
});
});

describe('extractCurrentValue', () => {
it('returns the correct value when the value is a range', () => {
let observation = new ObservationModel(observationR4Factory.valueString('< 10').build(), fhirVersions.R4);
let observation2 = new ObservationModel(observationR4Factory.valueString('> 10').build(), fhirVersions.R4);

expect(component['extractCurrentValue'](observation)).toEqual([null, 10])
expect(component['extractCurrentValue'](observation2)).toEqual([10, null])
});

it('returns the correct value when the value is a single value', () => {
let observation = new ObservationModel(observationR4Factory.valueQuantity({ value: 5 }).build(), fhirVersions.R4);

expect(component['extractCurrentValue'](observation)).toEqual([5, 5])
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ export class ObservationBarChartComponent implements OnInit {
},
tooltip: {
callbacks: {
label: function(context) {
return `${context.dataset.label}: ${context.dataset.dataLabels[context.dataIndex]}`;
}
label: this.formatTooltip
}
}
},
Expand All @@ -55,14 +53,7 @@ export class ObservationBarChartComponent implements OnInit {
// @ts-ignore
tooltip: {
callbacks: {
label: function(context) {
let label = `${context.dataset.label}: ${context.parsed.x}`;

if (context.dataset.dataLabels[context.dataIndex]) {
return `${label} ${context.dataset.dataLabels[context.dataIndex]}`;
}
return label;
}
label: this.formatTooltip
}
}
}
Expand Down Expand Up @@ -126,43 +117,63 @@ export class ObservationBarChartComponent implements OnInit {
let referenceRanges = []

for(let observation of this.observations) {
let refRange = observation.reference_range;
referenceRanges.push(this.extractReferenceRange(observation));
this.barChartData[0]['dataLabels'].push(observation.reference_range.display());

referenceRanges.push([refRange.low || 0, refRange.high || 0]);
currentValues.push(this.extractCurrentValue(observation));
this.barChartData[1]['dataLabels'].push(observation.value_model.display());

let value = observation.value_object;
this.barChartLabels.push(this.formatDate(observation.effective_date))
}

if (value.range) {
currentValues.push([value.range.low, value.range.high]);
} else {
currentValues.push([value.value, value.value])
}
let xAxisMax = Math.max(Math.max(...currentValues.flat()), Math.max(...referenceRanges.flat())) * 1.3;
this.barChartOptions.scales['x']['max'] = xAxisMax

if (observation.effective_date) {
this.barChartLabels.push(formatDate(observation.effective_date, "mediumDate", "en-US", undefined));
} else {
this.barChartLabels.push('Unknown date');
}
// @ts-ignore
this.barChartData[0].data = this.updateNullMax(referenceRanges, xAxisMax);
// @ts-ignore
this.barChartData[1].data = this.updateNullMax(currentValues, xAxisMax);

this.barChartData[0]['dataLabels'].push(observation.referenceRangeDisplay());
this.barChartData[1]['dataLabels'].push(observation.value_quantity_unit);
}
this.chartHeight = defaultChartHeight + (defaultChartEntryHeight * currentValues.length)
}

let xAxisMax = Math.max(...currentValues.map(set => set[1])) * 1.3;
this.barChartOptions.scales['x']['max'] = xAxisMax
private extractReferenceRange(observation: ObservationModel): [number, number] {
let refRange = observation.reference_range;

let updatedRefRanges = referenceRanges.map(range => {
if (range[0] && !range[1]) {
return [range[0], xAxisMax]
return [refRange.low_value || 0, refRange.high_value || 0]
}

private extractCurrentValue(observation: ObservationModel): [any, any] {
let valueObject = observation.value_model.valueObject();

if (valueObject.range) {
return [valueObject.range.low, valueObject.range.high];
} else {
return [valueObject.value, valueObject.value]
}
}

// Helper method to update any [number, null] set to [number, max].
// Necessary in order to display greater-than ranges that have no upper bound.
private updateNullMax(array: any[][], max: number): any[][] {
return array.map(values => {
if (values[0] && !values[1]) {
return [values[0], max]
} else {
return [range[0], range[1]]
return [values[0], values[1]]
}
});
}

// @ts-ignore
this.barChartData[0].data = updatedRefRanges
this.barChartData[1].data = currentValues
private formatDate(date: string | number | Date): string {
if (date) {
return formatDate(date, "mediumDate", "en-US", undefined);
} else {
return 'Unknown date';
}
}

this.chartHeight = defaultChartHeight + (defaultChartEntryHeight * currentValues.length)
private formatTooltip(context) {
return `${context.dataset.label}: ${context.dataset.dataLabels[context.dataIndex]}`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,42 +29,54 @@ type Story = StoryObj<ObservationBarChartComponent>;

export const NoRange: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.build(), fhirVersions.R4)]
observations: [new ObservationModel(observationR4Factory.valueQuantity().build(), fhirVersions.R4)]
}
};

export const ValueStringWithRange: Story = {
export const RangedValueQuantity: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.valueQuantity({ comparator: '<' }).build(), fhirVersions.R4)]
}
};

export const RangedValueStringWithReferenceRange: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.valueString('<10 IntlUnit/mL').referenceRangeOnlyHigh(50).build(), fhirVersions.R4)]
}
};

export const ValueInteger: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.valueInteger().build(), fhirVersions.R4)]
}
};

export const Range: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.referenceRange().build(), fhirVersions.R4)]
observations: [new ObservationModel(observationR4Factory.valueQuantity().referenceRange().build(), fhirVersions.R4)]
}
};

export const RangeOnlyLow: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.referenceRangeOnlyLow().build(), fhirVersions.R4)]
observations: [new ObservationModel(observationR4Factory.valueQuantity().referenceRangeOnlyLow().build(), fhirVersions.R4)]
}
};

export const RangeOnlyLowText: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.referenceRangeStringOnlyLow().build(), fhirVersions.R4)]
observations: [new ObservationModel(observationR4Factory.valueQuantity().referenceRangeStringOnlyLow().build(), fhirVersions.R4)]
}
};

export const RangeOnlyHigh: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.referenceRangeOnlyHigh().build(), fhirVersions.R4)]
observations: [new ObservationModel(observationR4Factory.valueQuantity().referenceRangeOnlyHigh().build(), fhirVersions.R4)]
}
};

export const RangeOnlyHighText: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.referenceRangeStringOnlyHigh().build(), fhirVersions.R4)]
observations: [new ObservationModel(observationR4Factory.valueQuantity().referenceRangeStringOnlyHigh().build(), fhirVersions.R4)]
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<div class="table-responsive">

<table class="table">
<thead>
<tr>
<th *ngFor="let header of headers">
{{header}}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of rows">
<td *ngFor="let data of row">
{{data}}
</td>
</tr>
</tbody>
</table>

</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
tbody tr td:first-child {
font-weight: bold;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ObservationTableComponent } from './observation-table.component';
import { ObservationModel } from 'src/lib/models/resources/observation-model';
import { observationR4Factory } from 'src/lib/fixtures/factories/r4/resources/observation-r4-factory';
import { fhirVersions } from 'src/lib/models/constants';
import { By } from '@angular/platform-browser';

describe('ObservationTableComponent', () => {
let component: ObservationTableComponent;
let fixture: ComponentFixture<ObservationTableComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ ObservationTableComponent ]
})
.compileComponents();

fixture = TestBed.createComponent(ObservationTableComponent);
component = fixture.componentInstance;
});

it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});

it('should display reference range column if any observations have a reference range', () => {
component.observations = [
new ObservationModel(observationR4Factory.valueQuantity().build(), fhirVersions.R4),
new ObservationModel(observationR4Factory.valueCodeableConcept().build(), fhirVersions.R4),
new ObservationModel(observationR4Factory.valueString().referenceRange().build(), fhirVersions.R4),
]
fixture.detectChanges();

expect(component.headers).toEqual(['Date', 'Result', 'Reference Range']);
expect(fixture.debugElement.queryAll(By.css('th')).length).toEqual(3);
});


it('should not display reference range column if no observations have a reference range', () => {
component.observations = [
new ObservationModel(observationR4Factory.valueQuantity().build(), fhirVersions.R4),
new ObservationModel(observationR4Factory.valueCodeableConcept().build(), fhirVersions.R4),
new ObservationModel(observationR4Factory.valueString().build(), fhirVersions.R4),
]
fixture.detectChanges();

expect(component.headers).toEqual(['Date', 'Result']);
expect(fixture.debugElement.queryAll(By.css('th')).length).toEqual(2);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Component, Input, OnInit } from '@angular/core';
import { ObservationModel } from '../../../../../lib/models/resources/observation-model';
import { CommonModule, formatDate } from '@angular/common';

@Component({
standalone: true,
selector: 'observation-table',
imports: [ CommonModule ],
templateUrl: './observation-table.component.html',
styleUrls: ['./observation-table.component.scss']
})
export class ObservationTableComponent implements OnInit {
@Input() observations: ObservationModel[]

headers: string[] = []
rows: string[][] = []

constructor() { }

ngOnInit(): void {
if(!this.observations || !this.observations[0]) {
return;
}

let displayRange = this.rangeExists(this.observations);

if (displayRange) {
this.headers = ['Date', 'Result', 'Reference Range'];
this.rows = this.observations.map((observation) => {
return [this.formatDate(observation.effective_date), observation.value_model?.display(), observation.reference_range?.display()];
});
} else {
this.headers = ['Date', 'Result'];
this.rows = this.observations.map((observation) => {
return [this.formatDate(observation.effective_date), observation.value_model?.display()];
});
}
}

private rangeExists(observations: ObservationModel[]): boolean {
return observations.some((observation) => { return observation.reference_range?.hasValue() })
}

private formatDate(date: string | number | Date): string {
if (date) {
return formatDate(date, "mediumDate", "en-US", undefined);
} else {
return 'Unknown date';
}
}
}

0 comments on commit 6264415

Please sign in to comment.