Support displaying non-numerical observation results (#453)

* 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
This commit is contained in:
Jean Fernandez 2024-03-29 11:32:33 -04:00 committed by GitHub
parent 1bcf4aaf7e
commit 62644155c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1468 additions and 347 deletions

View File

@ -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;
@ -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])
});
});
});

View File

@ -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
}
}
},
@ -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
}
}
}
@ -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;
if (value.range) {
currentValues.push([value.range.low, value.range.high]);
} else {
currentValues.push([value.value, value.value])
}
if (observation.effective_date) {
this.barChartLabels.push(formatDate(observation.effective_date, "mediumDate", "en-US", undefined));
} else {
this.barChartLabels.push('Unknown date');
}
this.barChartData[0]['dataLabels'].push(observation.referenceRangeDisplay());
this.barChartData[1]['dataLabels'].push(observation.value_quantity_unit);
this.barChartLabels.push(this.formatDate(observation.effective_date))
}
let xAxisMax = Math.max(...currentValues.map(set => set[1])) * 1.3;
let xAxisMax = Math.max(Math.max(...currentValues.flat()), Math.max(...referenceRanges.flat())) * 1.3;
this.barChartOptions.scales['x']['max'] = xAxisMax
let updatedRefRanges = referenceRanges.map(range => {
if (range[0] && !range[1]) {
return [range[0], xAxisMax]
} else {
return [range[0], range[1]]
}
});
// @ts-ignore
this.barChartData[0].data = updatedRefRanges
this.barChartData[1].data = currentValues
this.barChartData[0].data = this.updateNullMax(referenceRanges, xAxisMax);
// @ts-ignore
this.barChartData[1].data = this.updateNullMax(currentValues, xAxisMax);
this.chartHeight = defaultChartHeight + (defaultChartEntryHeight * currentValues.length)
}
private extractReferenceRange(observation: ObservationModel): [number, number] {
let refRange = observation.reference_range;
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 [values[0], values[1]]
}
});
}
private formatDate(date: string | number | Date): string {
if (date) {
return formatDate(date, "mediumDate", "en-US", undefined);
} else {
return 'Unknown date';
}
}
private formatTooltip(context) {
return `${context.dataset.label}: ${context.dataset.dataLabels[context.dataIndex]}`;
}
}

View File

@ -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)]
}
};

View File

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

View File

@ -0,0 +1,3 @@
tbody tr td:first-child {
font-weight: bold;
}

View File

@ -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);
});
});

View File

@ -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';
}
}
}

View File

@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { fhirVersions } from "../../../../../lib/models/constants";
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';
// More on how to set up stories at: https://storybook.js.org/docs/angular/writing-stories/introduction
const meta: Meta<ObservationTableComponent> = {
title: 'Fhir Card/Common/ObservationTable',
component: ObservationTableComponent,
decorators: [
],
tags: ['autodocs'],
render: (args: ObservationTableComponent) => ({
props: {
backgroundColor: null,
...args,
},
}),
argTypes: {
observations: {
control: 'object',
}
},
};
export default meta;
type Story = StoryObj<ObservationTableComponent>;
export const ValueQuantity: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.valueQuantity().build(), fhirVersions.R4)]
}
};
export const ValueStringWithRange: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.valueString().referenceRange().build(), fhirVersions.R4)]
}
};
export const ValueCodableConcept: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.valueCodeableConcept().build(), fhirVersions.R4)]
}
};

View File

@ -0,0 +1,3 @@
<observation-bar-chart *ngIf="visualizationType == 'bar'" [observations]="observations"></observation-bar-chart>
<observation-table *ngIf="visualizationType == 'table'" [observations]="observations"></observation-table>

View File

@ -0,0 +1,62 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ObservationVisualizationComponent } from './observation-visualization.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('ObservationVisualizationComponent', () => {
let component: ObservationVisualizationComponent;
let fixture: ComponentFixture<ObservationVisualizationComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ ObservationVisualizationComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(ObservationVisualizationComponent);
component = fixture.componentInstance;
});
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should render a bar chart if the first observation supports it', () => {
component.observations = [new ObservationModel(observationR4Factory.valueQuantity().build(), fhirVersions.R4)]
fixture.detectChanges();
expect(component.visualizationType).toEqual('bar');
expect(fixture.debugElement.query(By.css('observation-bar-chart'))).toBeTruthy();
expect(fixture.debugElement.query(By.css('observation-table'))).toBeFalsy();
});
it('should render a table chart if that is all the first observation supports', () => {
component.observations = [new ObservationModel(observationR4Factory.valueCodeableConcept().build(), fhirVersions.R4)]
fixture.detectChanges();
expect(component.visualizationType).toEqual('table');
expect(fixture.debugElement.query(By.css('observation-table'))).toBeTruthy();
expect(fixture.debugElement.query(By.css('observation-bar-chart'))).toBeFalsy();
});
describe('pickVisualizationType', () => {
let barAndTable: ObservationModel;
let tableOnly: ObservationModel;
beforeEach(async () => {
barAndTable = new ObservationModel(observationR4Factory.valueQuantity().build(), fhirVersions.R4);
tableOnly = new ObservationModel(observationR4Factory.valueCodeableConcept().build(), fhirVersions.R4)
});
it('returns the preferredVisualization if the first observation supports that visualization type', () => {
expect(component['pickVisualizationType']('bar', [barAndTable, tableOnly])).toEqual('bar');
});
it('returns the first supported type for the first observation if the preferred visualization is not supported', () => {
expect(component['pickVisualizationType']('bar', [tableOnly, barAndTable])).toEqual('table');
});
});
});

View File

@ -0,0 +1,39 @@
import { Component, Input, OnInit } from '@angular/core';
import { ObservationModel } from '../../../../../lib/models/resources/observation-model';
import { CommonModule } from '@angular/common';
import { ObservationBarChartComponent } from '../observation-bar-chart/observation-bar-chart.component';
import { ObservationTableComponent } from '../observation-table/observation-table.component';
@Component({
standalone: true,
selector: 'observation-visualization',
imports: [ CommonModule, ObservationBarChartComponent, ObservationTableComponent ],
templateUrl: './observation-visualization.component.html',
styleUrls: ['./observation-visualization.component.scss']
})
export class ObservationVisualizationComponent implements OnInit {
@Input() observations: ObservationModel[]
@Input() preferredVisualizationType?: string = 'bar'
visualizationType: string = ''
constructor() { }
ngOnInit(): void {
if(!this.observations || !this.observations[0] || !this.observations[0].value_model) {
return;
}
this.visualizationType = this.pickVisualizationType(this.preferredVisualizationType, this.observations)
}
// Right now this is just looking at the first observation's visualization types. If the preferred type is one of the
// accepted types, then use it. Otherwise just use the first observation's first visualization type.
private pickVisualizationType(preferredType: string, observations: ObservationModel[]): string {
if (preferredType && observations[0].value_model.visualizationTypes().includes(preferredType)) {
return preferredType;
} else {
return observations[0].value_model.visualizationTypes()[0];
}
}
}

View File

@ -0,0 +1,58 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { fhirVersions } from "../../../../../lib/models/constants";
import { ObservationVisualizationComponent } from './observation-visualization.component';
import { ObservationModel } from 'src/lib/models/resources/observation-model';
import { observationR4Factory } from 'src/lib/fixtures/factories/r4/resources/observation-r4-factory';
// More on how to set up stories at: https://storybook.js.org/docs/angular/writing-stories/introduction
const meta: Meta<ObservationVisualizationComponent> = {
title: 'Fhir Card/Common/ObservationVisualization',
component: ObservationVisualizationComponent,
decorators: [
],
tags: ['autodocs'],
render: (args: ObservationVisualizationComponent) => ({
props: {
backgroundColor: null,
...args,
},
}),
argTypes: {
observations: {
control: 'object',
}
},
};
export default meta;
type Story = StoryObj<ObservationVisualizationComponent>;
export const ValueQuantity: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.valueQuantity().build(), fhirVersions.R4)]
}
};
export const ValueString: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.valueString().build(), fhirVersions.R4)]
}
};
export const ValueInteger: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.valueInteger().build(), fhirVersions.R4)]
}
};
export const ValueCodableConcept: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.valueCodeableConcept().build(), fhirVersions.R4)]
}
};
export const ValueBoolean: Story = {
args: {
observations: [new ObservationModel(observationR4Factory.valueBoolean().build(), fhirVersions.R4)]
}
};

View File

@ -29,6 +29,8 @@ import {FhirCardOutletDirective} from './fhir-card/fhir-card-outlet.directive';
import { EncounterComponent } from './resources/encounter/encounter.component';
import { RtfComponent } from './datatypes/rtf/rtf.component';
import { ObservationBarChartComponent } from './common/observation-bar-chart/observation-bar-chart.component';
import { ObservationTableComponent } from './common/observation-table/observation-table.component';
import { ObservationVisualizationComponent } from './common/observation-visualization/observation-visualization.component';
@ -38,6 +40,8 @@ import { ObservationBarChartComponent } from './common/observation-bar-chart/obs
CommonModule,
BadgeComponent,
ObservationBarChartComponent,
ObservationTableComponent,
ObservationVisualizationComponent,
//datatypes
TableComponent,
BinaryTextComponent,
@ -78,6 +82,8 @@ import { ObservationBarChartComponent } from './common/observation-bar-chart/obs
BadgeComponent,
TableComponent,
ObservationBarChartComponent,
ObservationTableComponent,
ObservationVisualizationComponent,
//datatypes
BinaryTextComponent,
CodableConceptComponent,

View File

@ -14,7 +14,7 @@
<p class="az-content-text mg-b-20">Observations are a central element in healthcare, used to support diagnosis, monitor progress, determine baselines and patterns and even capture demographic characteristics.</p>
<fhir-ui-table [displayModel]="displayModel" [tableData]="tableData"></fhir-ui-table>
<observation-bar-chart [observations]="[displayModel]"></observation-bar-chart>
<observation-visualization *ngIf="displayVisualization" [observations]="[displayModel]"></observation-visualization>
</div>
<div *ngIf="showDetails" class="card-footer">
<a class="float-right" [routerLink]="['/explore', displayModel?.source_id, 'resource', displayModel?.source_resource_id]">details</a>

View File

@ -1,7 +1,11 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ObservationComponent } from './observation.component';
import {RouterTestingModule} from '@angular/router/testing';
import { RouterTestingModule } from '@angular/router/testing';
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('ObservationComponent', () => {
let component: ObservationComponent;
@ -15,10 +19,26 @@ describe('ObservationComponent', () => {
fixture = TestBed.createComponent(ObservationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should not display a visualization if table is the only visualization type', () => {
component.displayModel = new ObservationModel(observationR4Factory.valueCodeableConcept().build(), fhirVersions.R4);
fixture.detectChanges();
expect(component.displayVisualization).toBeFalse();
expect(fixture.debugElement.query(By.css('observation-visualization'))).toBeFalsy();
});
it('should display a visualization if there is a non-table visualization type', () => {
component.displayModel = new ObservationModel(observationR4Factory.valueQuantity().build(), fhirVersions.R4);
fixture.detectChanges();
expect(component.displayVisualization).toBeTrue();
expect(fixture.debugElement.query(By.css('observation-visualization'))).toBeTruthy();
});
});

View File

@ -6,11 +6,11 @@ import {TableComponent} from '../../common/table/table.component';
import {Router, RouterModule} from '@angular/router';
import {TableRowItem, TableRowItemDataType} from '../../common/table/table-row-item';
import {ObservationModel} from '../../../../../lib/models/resources/observation-model';
import { ObservationBarChartComponent } from 'src/app/components/fhir-card/common/observation-bar-chart/observation-bar-chart.component';
import { ObservationVisualizationComponent } from '../../common/observation-visualization/observation-visualization.component';
@Component({
standalone: true,
imports: [CommonModule, BadgeComponent, TableComponent, RouterModule, NgbCollapseModule, ObservationBarChartComponent],
imports: [CommonModule, BadgeComponent, TableComponent, RouterModule, NgbCollapseModule, ObservationVisualizationComponent],
providers: [],
selector: 'fhir-observation',
templateUrl: './observation.component.html',
@ -22,6 +22,7 @@ export class ObservationComponent implements OnInit {
@Input() isCollapsed: boolean = false
tableData: TableRowItem[] = []
displayVisualization: boolean = true
constructor(public changeRef: ChangeDetectorRef, public router: Router) { }
@ -30,6 +31,14 @@ export class ObservationComponent implements OnInit {
return
}
let visualizationTypes = this.displayModel?.value_model?.visualizationTypes()
// If only table is allowed, just don't display anything since we are already displaying
// everything in tabular format.
if (visualizationTypes.length == 1 && visualizationTypes[0] == 'table') {
this.displayVisualization = false
}
this.tableData.push(
{
label: 'Issued on',
@ -40,23 +49,23 @@ export class ObservationComponent implements OnInit {
label: 'Subject',
data: this.displayModel?.subject,
data_type: TableRowItemDataType.Reference,
enabled: !!this.displayModel?.subject ,
enabled: !!this.displayModel?.subject,
},
{
label: 'Coding',
data: this.displayModel?.code,
data_type: TableRowItemDataType.Coding,
data_type: TableRowItemDataType.CodableConcept,
enabled: !!this.displayModel?.code,
},
{
label: 'Value',
data: [this.displayModel?.value_quantity_value,this.displayModel?.value_quantity_unit].join(" "),
enabled: !!this.displayModel?.value_quantity_value,
data: this.displayModel?.value_model?.display(),
enabled: !!this.displayModel?.value_model,
},
{
label: 'Reference',
data: this.displayModel.referenceRangeDisplay(),
enabled: !!this.displayModel?.reference_range,
data: this.displayModel?.reference_range.display(),
enabled: !!this.displayModel?.reference_range.hasValue(),
}
)
}

View File

@ -5,6 +5,7 @@ import { ObservationComponent } from "./observation.component";
import { ObservationModel } from "../../../../../lib/models/resources/observation-model";
import { RouterTestingModule } from '@angular/router/testing';
import { observationR4Factory } from 'src/lib/fixtures/factories/r4/resources/observation-r4-factory';
import { codeableConceptR4Factory } from 'src/lib/fixtures/factories/r4/datatypes/codeable-concept-r4-factory';
const meta: Meta<ObservationComponent> = {
title: 'Fhir Card/Observation',
@ -34,7 +35,7 @@ const meta: Meta<ObservationComponent> = {
export default meta;
type Story = StoryObj<ObservationComponent>;
let observation = new ObservationModel(observationR4Factory.referenceRange().build(), fhirVersions.R4);
let observation = new ObservationModel(observationR4Factory.valueQuantity().referenceRange().build(), fhirVersions.R4);
observation.source_id = '123-456-789'
observation.source_resource_id = '123-456-789'
export const Entry: Story = {
@ -42,3 +43,21 @@ export const Entry: Story = {
displayModel: observation
}
};
let observation2 = new ObservationModel(observationR4Factory.valueCodeableConcept().code(codeableConceptR4Factory.text('Covid Test').build()).build(), fhirVersions.R4);
observation.source_id = '123-456-789'
observation.source_resource_id = '123-456-789'
export const CodeableConcept: Story = {
args: {
displayModel: observation2
}
};
let observation3 = new ObservationModel(observationR4Factory.dataAbsent().build(), fhirVersions.R4);
observation.source_id = '123-456-789'
observation.source_resource_id = '123-456-789'
export const DataAbsent: Story = {
args: {
displayModel: observation3
}
};

View File

@ -53,8 +53,8 @@
<app-glossary-lookup [code]="observationCode" [codeSystem]="'http://loinc.org'"></app-glossary-lookup>
</div>
<div class="col-12">
<observation-bar-chart [observations]="observationModels"></observation-bar-chart>
<div class="col-12 visualization-container">
<observation-visualization [observations]="observationModels"></observation-visualization>
</div>
</div>

View File

@ -0,0 +1,5 @@
.visualization-container {
max-height: 20em;
display: inline-block;
overflow: scroll;
}

View File

@ -2,6 +2,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReportLabsObservationComponent } from './report-labs-observation.component';
import { PipesModule } from '../../pipes/pipes.module';
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
import { RouterTestingModule } from '@angular/router/testing';
import { ObservationVisualizationComponent } from '../fhir-card/common/observation-visualization/observation-visualization.component';
describe('ReportLabsObservationComponent', () => {
let component: ReportLabsObservationComponent;
@ -9,7 +11,7 @@ describe('ReportLabsObservationComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PipesModule],
imports: [PipesModule, RouterTestingModule, ObservationVisualizationComponent],
declarations: [ ReportLabsObservationComponent, NgbCollapse ],
})
.compileComponents();

View File

@ -4,7 +4,7 @@ import { DecoratorFunction } from '@storybook/types';
import { ReportLabsObservationComponent } from './report-labs-observation.component'
import { PipesModule } from 'src/app/pipes/pipes.module';
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
import { ResourceFhir } from 'src/app/models/fasten/resource_fhir';
import { IResourceRaw, ResourceFhir } from 'src/app/models/fasten/resource_fhir';
import { GlossaryLookupComponent } from '../glossary-lookup/glossary-lookup.component';
import { NgChartsModule } from 'ng2-charts';
import { HTTP_CLIENT_TOKEN } from 'src/app/dependency-injection';
@ -14,7 +14,9 @@ import { Observable, of } from 'rxjs';
import R4Example1Json from "../../../lib/fixtures/r4/resources/observation/example1.json";
import { Html as GlossaryLookupHtml } from '../glossary-lookup/glossary-lookup.stories';
import { ObservationBarChartComponent } from '../fhir-card/common/observation-bar-chart/observation-bar-chart.component';
import { ObservationVisualizationComponent } from '../fhir-card/common/observation-visualization/observation-visualization.component';
import { fhirVersions } from 'src/lib/models/constants';
import { observationR4Factory } from 'src/lib/fixtures/factories/r4/resources/observation-r4-factory';
const withHttpClientProvider: DecoratorFunction<any> = (storyFunc, context) => {
@ -40,7 +42,7 @@ const meta: Meta<ReportLabsObservationComponent> = {
decorators: [
withHttpClientProvider,
moduleMetadata({
imports: [PipesModule, GlossaryLookupComponent, NgChartsModule, RouterTestingModule, HttpClientModule, ObservationBarChartComponent],
imports: [PipesModule, GlossaryLookupComponent, NgChartsModule, RouterTestingModule, HttpClientModule, ObservationVisualizationComponent],
declarations: [ NgbCollapse ],
providers: [],
})
@ -100,3 +102,35 @@ export const Entry: Story = {
...GlossaryLookupHtml.parameters
}
};
const observation3: ResourceFhir = {
source_id: '',
source_resource_id: '',
source_resource_type: 'Observation',
fhir_version: '4',
sort_title: 'sort',
sort_date: new Date(),
resource_raw: observationR4Factory.valueCodeableConcept().build() as IResourceRaw,
};
const observation4: ResourceFhir = {
source_id: '',
source_resource_id: '',
source_resource_type: 'Observation',
fhir_version: '4',
sort_title: 'sort',
sort_date: new Date(),
resource_raw: observationR4Factory.valueCodeableConcept().build() as IResourceRaw,
};
export const CodableConcept: Story = {
args: {
observations: [observation3, observation4],
observationCode: '788-0',
observationTitle: 'Erythrocyte distribution width [Ratio] by Automated count',
},
parameters: {
...GlossaryLookupHtml.parameters
}
};

View File

@ -0,0 +1,30 @@
import { Factory } from 'fishery';
class CodableConceptR4Factory extends Factory<{}> {
text(value?: string) {
return this.params({
text: value || 'Glucose [Moles/volume] in Blood'
})
}
coding(params?: {}) {
let p = params || {}
return this.params({
text: null,
coding: [
{
system: p['system'] || 'http://loinc.org',
code: p['code'] || '15074-8',
display: p['display'] || 'Glucose [Moles/volume] in Blood'
}
]
})
}
}
export const codeableConceptR4Factory = CodableConceptR4Factory.define(() => (
{
text: 'Glucose [Moles/volume] in Blood'
}
));

View File

@ -0,0 +1,38 @@
import { Factory } from 'fishery';
class QuantityR4Factory extends Factory<{}> {
value(value?: number) {
return this.params({
value: value || 5.5
})
}
comparator(comparator: string) {
return this.params({
comparator: comparator
})
}
unit(unit?: string) {
return this.params({
unit: unit || 'mmol/l'
})
}
system(system?: string) {
return this.params({
system: system || 'http://unitsofmeasure.org'
})
}
code(code?: string) {
return this.params({
code: code || 'mmol/l'
})
}
}
export const quantityR4Factory = QuantityR4Factory.define(() => (
{}
));

View File

@ -0,0 +1,41 @@
import { Factory } from 'fishery';
class ReferenceRangeR4Factory extends Factory<{}> {
text(value?: string) {
return this.params({
text: value || '<10'
})
}
high(params?: {}) {
let p = params || {}
return this.params({
text: null,
high: {
value: p['value'] || 6.2,
unit: p['unit'] || '',
system: p['system'] || '',
code: p['code'] || '',
}
})
}
low(params?: {}) {
let p = params || {}
return this.params({
text: null,
low: {
value: p['value'] || 6.2,
unit: p['unit'] || '',
system: p['system'] || '',
code: p['code'] || '',
}
})
}
}
export const referenceRangeR4Factory = ReferenceRangeR4Factory.define(() => (
{
text: '<10'
}
));

View File

@ -1,6 +1,13 @@
import { Factory } from 'fishery';
import { codeableConceptR4Factory } from '../datatypes/codeable-concept-r4-factory';
class ObservationR4Factory extends Factory<{}> {
code(codeableConcept: {}) {
return this.params({
code: codeableConcept || codeableConceptR4Factory.build()
})
}
valueString(value?: string) {
return this.params({
@ -9,19 +16,66 @@ class ObservationR4Factory extends Factory<{}> {
})
}
valueQuantity(params: {}) {
valueQuantity(params?: {}) {
let p = params || {}
return this.params({
valueQuantity: {
value: params['value'] || 6.3,
unit: params['unit'] || 'mmol/l',
value: p['value'] || 6.3,
unit: p['unit'] || 'mmol/l',
system: 'http://unitsofmeasure.org',
code: params['code'] || 'mmol/L',
comparator: params['comparator']
code: p['code'] || 'mmol/L',
comparator: p['comparator']
}
})
}
referenceRange(high?: number, low?: number) {
valueInteger(value?: number) {
return this.params({
valueQuantity: null,
valueInteger: value || 4.9
})
}
valueCodeableConcept() {
return this.params({
valueQuantity: null,
valueCodeableConcept: {
coding: [
{
system: 'http://snomed.info/sct',
code: '260373001',
display: 'Detected (qualifier value)',
userSelected: false
}
],
text: 'Detected'
},
})
}
valueBoolean(value?: boolean) {
return this.params({
valueQuantity: null,
valueBoolean: value || true
})
}
dataAbsent() {
return this.params({
dataAbsentReason: {
coding: [
{
system: 'http://terminology.hl7.org/CodeSystem/data-absent-reason',
code: 'unknown',
display: 'Error'
}
],
text: 'Error'
}
})
}
referenceRange(low?: number, high?: number) {
return this.params({
referenceRange: [
{
@ -143,12 +197,6 @@ export const observationR4Factory = ObservationR4Factory.define(() => (
display: 'A. Langeveld'
}
],
valueQuantity: {
value: 6.3,
unit: 'mmol/l',
system: 'http://unitsofmeasure.org',
code: 'mmol/L'
},
interpretation: [
{
coding: [

View File

@ -0,0 +1,27 @@
import { BooleanModel } from "./boolean-model";
describe('ObservationValueBooleanModel', () => {
it('should create an instance', () => {
expect(new BooleanModel(false)).toBeTruthy();
});
it('returns the correct visualization types', () => {
expect(new BooleanModel(false).visualizationTypes()).toEqual(['table']);
});
it('sets valueObject correctl', () => {
let model = new BooleanModel(true);
let model2 = new BooleanModel(false);
expect(model.valueObject()).toEqual({ value: true });
expect(model2.valueObject()).toEqual({ value: false });
});
it ('returns correct display', () => {
let model = new BooleanModel(true);
let model2 = new BooleanModel(false);
expect(model.display()).toEqual('true');
expect(model2.display()).toEqual('false');
});
});

View File

@ -0,0 +1,22 @@
import { ObservationValue, ValueObject } from "../resources/observation-model";
export class BooleanModel implements ObservationValue {
source: boolean
constructor(value: boolean) {
this.source = value;
}
visualizationTypes(): string[] {
return ['table'];
}
display(): string {
return this.source.toString();
}
valueObject(): ValueObject {
return { value: this.source };
}
}

View File

@ -1,8 +1,19 @@
export interface CodingModel {
import _ from "lodash";
export class CodingModel {
display?: string
code?: string
system?: string
value?: any
unit?: string
type?: any
constructor(fhirData: any) {
this.display = _.get(fhirData, 'display');
this.code = _.get(fhirData, 'code');
this.system = _.get(fhirData, 'system');
this.value = _.get(fhirData, 'value');
this.unit = _.get(fhirData, 'unit');
this.type = _.get(fhirData, 'type');
}
}

View File

@ -0,0 +1,48 @@
import { codeableConceptR4Factory } from "src/lib/fixtures/factories/r4/datatypes/codeable-concept-r4-factory";
import { DataAbsentReasonModel } from "./data-absent-reason-model";
describe('DataAbsentReasonModel', () => {
it('should create an instance', () => {
expect(new DataAbsentReasonModel({})).toBeTruthy();
});
it('returns the correct visualization types', () => {
expect(new DataAbsentReasonModel({}).visualizationTypes()).toEqual(['table']);
});
describe('valueObject', () => {
it('uses text if it is set', () => {
let model = new DataAbsentReasonModel(codeableConceptR4Factory.text('Unknown').build());
expect(model.valueObject()).toEqual({ value: 'Unknown' });
});
it('uses the first coding display if text is not set', () => {
let model = new DataAbsentReasonModel(codeableConceptR4Factory.coding({ display: 'Unknown' }).build());
expect(model.valueObject()).toEqual({ value: 'Unknown' });
});
it('does not error when data is malformed', () => {
expect(new DataAbsentReasonModel({}).valueObject()).toEqual({ value: null });
});
});
describe('display', () => {
it('uses text if it is set', () => {
let model = new DataAbsentReasonModel(codeableConceptR4Factory.text('unknown').build());
expect(model.display()).toEqual('unknown (data absent)');
});
it('uses the first coding display if text is not set', () => {
let model = new DataAbsentReasonModel(codeableConceptR4Factory.coding({ display: 'Unknown' }).build());
expect(model.display()).toEqual('Unknown (data absent)');
});
it('does not error when data is malformed', () => {
expect(new DataAbsentReasonModel({}).display()).toEqual('(data absent)');
});
});
});

View File

@ -0,0 +1,20 @@
import { CodeableConcept, Coding } from "fhir/r4";
import { ObservationValueCodeableConceptModel } from "./observation-value-codeable-concept-model";
// Technically not its own fhir datatype. But for observations, either a value or dataAbsentReason
// should be set. This is a wrapper around ObservationValueCodeableConceptModel to hopefully add a little
// clarity to the display string. Seems like some of the reasons given are things like "Unknown" and "Error".
// This makes it so "(data absent)" is appended to the string.
export class DataAbsentReasonModel extends ObservationValueCodeableConceptModel {
source: CodeableConcept
coding?: Coding[]
text?: string
constructor(fhirData: any) {
super(fhirData)
}
display(): string {
return `${this.valueObject().value?.toString() || ''} (data absent)`.trim();
}
}

View File

@ -0,0 +1,21 @@
import { IntegerModel } from "./integer-model";
describe('ObservationValueIntegerModel', () => {
it('should create an instance', () => {
expect(new IntegerModel(5)).toBeTruthy();
});
it('returns the correct visualization types', () => {
expect(new IntegerModel(5).visualizationTypes()).toEqual(['bar', 'table']);
});
it('sets valueObject correctl', () => {
let model = new IntegerModel(6.3);
expect(model.valueObject()).toEqual({ value: 6.3 });
});
it('returns the correct display', () => {
expect(new IntegerModel(5).display()).toEqual('5');
});
});

View File

@ -0,0 +1,21 @@
import { ObservationValue, ValueObject } from "../resources/observation-model";
export class IntegerModel implements ObservationValue {
soruceValue: number
constructor(value: number) {
this.soruceValue = value;
}
visualizationTypes(): string[] {
return ['bar', 'table'];
}
display(): string {
return this.soruceValue.toString();
}
valueObject(): ValueObject {
return { value: this.soruceValue }
}
}

View File

@ -0,0 +1,48 @@
import { codeableConceptR4Factory } from "src/lib/fixtures/factories/r4/datatypes/codeable-concept-r4-factory";
import { ObservationValueCodeableConceptModel } from "./observation-value-codeable-concept-model";
describe('ObservationValueCodeableConceptModel', () => {
it('should create an instance', () => {
expect(new ObservationValueCodeableConceptModel({})).toBeTruthy();
});
it('returns the correct visualization types', () => {
expect(new ObservationValueCodeableConceptModel({}).visualizationTypes()).toEqual(['table']);
});
describe('valueObject', () => {
it('uses text if it is set', () => {
let model = new ObservationValueCodeableConceptModel(codeableConceptR4Factory.text('Negative for Chlamydia Trachomatis rRNA').build());
expect(model.valueObject()).toEqual({ value: 'Negative for Chlamydia Trachomatis rRNA' });
});
it('uses the first coding display if text is not set', () => {
let model = new ObservationValueCodeableConceptModel(codeableConceptR4Factory.coding({ display: 'Negative' }).build());
expect(model.valueObject()).toEqual({ value: 'Negative' });
});
it('does not error when data is malformed', () => {
expect(new ObservationValueCodeableConceptModel({}).valueObject()).toEqual({ value: null });
});
});
describe('display', () => {
it('uses text if it is set', () => {
let model = new ObservationValueCodeableConceptModel(codeableConceptR4Factory.text('Negative for Chlamydia Trachomatis rRNA').build());
expect(model.display()).toEqual('Negative for Chlamydia Trachomatis rRNA');
});
it('uses the first coding display if text is not set', () => {
let model = new ObservationValueCodeableConceptModel(codeableConceptR4Factory.coding({ display: 'Negative' }).build());
expect(model.display()).toEqual('Negative');
});
it('does not error when data is malformed', () => {
expect(new ObservationValueCodeableConceptModel({}).display()).toEqual('');
});
});
});

View File

@ -0,0 +1,35 @@
import { CodeableConcept, Coding } from "fhir/r4";
import { ObservationValue, ValueObject } from "../resources/observation-model";
// TODO: merge with the normal CodeableConceptModel.
export class ObservationValueCodeableConceptModel implements ObservationValue {
source: CodeableConcept
coding?: Coding[]
text?: string
constructor(fhirData: any) {
this.source = fhirData;
this.coding = fhirData.coding
this.text = fhirData.text
}
visualizationTypes(): string[] {
return ['table'];
}
display(): string {
return this.valueObject().value?.toString() || '';
}
valueObject(): ValueObject {
if (this.text) {
return { value: this.text }
}
if (!this.coding) {
return { value: null }
}
return { value: this.coding[0].display }
}
}

View File

@ -0,0 +1,39 @@
import { quantityR4Factory } from "src/lib/fixtures/factories/r4/datatypes/quantity-r4-factory";
import { QuantityModel } from "./quantity-model";
describe('QuantityModel', () => {
it('should create an instance', () => {
expect(new QuantityModel({})).toBeTruthy();
});
it('returns the correct visualization types', () => {
expect(new QuantityModel({}).visualizationTypes()).toEqual(['bar', 'table']);
});
it('returns the correct display', () => {
let quantity = new QuantityModel(quantityR4Factory.value(8).comparator('<').build());
let quantity2 = new QuantityModel(quantityR4Factory.value(8.2).comparator('<').unit('g').build());
let quantity3 = new QuantityModel(quantityR4Factory.value(9.5).unit('g').build());
expect(quantity.display()).toEqual('< 8')
expect(quantity2.display()).toEqual('< 8.2 g')
expect(quantity3.display()).toEqual('9.5 g')
});
describe('valueObject', () => {
it('sets value if there is no comparator', () => {
let quantity = new QuantityModel(quantityR4Factory.value(6.3).build());
expect(quantity.valueObject()).toEqual({ value: 6.3 });
});
it('sets range correctly if there is a comparator', () => {
let quantity = new QuantityModel(quantityR4Factory.value(8).comparator('<').build());
let quantity2 = new QuantityModel(quantityR4Factory.value(8).comparator('>').build());
expect(quantity.valueObject()).toEqual({ range: { low: null, high: 8 } });
expect(quantity2.valueObject()).toEqual({ range: { low: 8, high: null } });
});
});
});

View File

@ -0,0 +1,46 @@
import { Quantity } from 'fhir/r4';
import { ObservationValue, ValueObject } from '../resources/observation-model';
import _ from 'lodash';
// https://www.hl7.org/fhir/R4/datatypes.html#Quantity
// Also used for SimpleQuantity which is Quantity but with the rule that 'comparator' should not be set
export class QuantityModel implements Quantity, ObservationValue {
value?: number
comparator?: '<' | '<=' | '>=' | '>'
unit?: string
system?: string
code?: string
constructor(fhirData: any) {
this.value = _.get(fhirData, 'value');
this.comparator = _.get(fhirData, 'comparator');
this.unit = _.get(fhirData, 'unit');
this.system = _.get(fhirData, 'system');
this.code = _.get(fhirData, 'code');
}
visualizationTypes(): string[] {
return ['bar', 'table'];
}
hasValue(): boolean {
return !!this.value;
}
display(): string {
return [this.comparator, this.value, this.unit].join(' ').trim()
}
valueObject(): ValueObject {
switch (this.comparator) {
case '<':
case '<=':
return { range: { low: null, high: this.value } };
case '>':
case '>=':
return { range: { low: this.value, high: null } };
default:
return { value: this.value }
}
}
}

View File

@ -0,0 +1,41 @@
import { quantityR4Factory } from "src/lib/fixtures/factories/r4/datatypes/quantity-r4-factory";
import { RangeModel } from "./range-model";
describe('RangeModel', () => {
it('should create an instance', () => {
expect(new RangeModel({})).toBeTruthy();
});
describe('display', () => {
it('returns the correct display when there is only a low', () => {
let range = new RangeModel({ low: quantityR4Factory.value(6.2).build() })
let range2 = new RangeModel({ low: quantityR4Factory.value(6.2).unit('g').build() })
expect(range.display()).toEqual('> 6.2')
expect(range2.display()).toEqual('> 6.2 g')
});
it('returns the correct display when there is only a high', () => {
let range = new RangeModel({ high: quantityR4Factory.value(6.2).build() })
let range2 = new RangeModel({ high: quantityR4Factory.value(6.2).unit('g').build() })
expect(range.display()).toEqual('< 6.2')
expect(range2.display()).toEqual('< 6.2 g')
});
it('returns the correct display when there both a high and low', () => {
let range = new RangeModel({ low: quantityR4Factory.value(6.2).build(), high: quantityR4Factory.value(8.9).build() })
let range2 = new RangeModel({ low: quantityR4Factory.value(6.2).unit('g').build(), high: quantityR4Factory.value(8.9).unit('g').build() })
expect(range.display()).toEqual('6.2 \u{2013} 8.9')
expect(range2.display()).toEqual('6.2 g \u{2013} 8.9 g')
});
it('does not error if data is malformed', () => {
expect((new RangeModel(null)).display()).toEqual('')
expect((new RangeModel({})).display()).toEqual('')
});
})
});

View File

@ -0,0 +1,25 @@
import { Range } from "fhir/r4";
import { QuantityModel } from "./quantity-model";
import _ from "lodash";
export class RangeModel implements Range {
low?: QuantityModel
high?: QuantityModel
constructor(fhirData: any) {
this.low = new QuantityModel(_.get(fhirData, 'low'));
this.high = new QuantityModel(_.get(fhirData, 'high'));
}
display(): string {
if (this.low.hasValue() && this.high.hasValue()) {
return [this.low.display(), '\u{2013}', this.high.display()].join(' ').trim();
} else if (this.low.hasValue()) {
return ['>', this.low.display()].join(' ').trim();
} else if (this.high.hasValue()) {
return ['<', this.high.display()].join(' ').trim();
}
return '';
}
}

View File

@ -0,0 +1,72 @@
import { referenceRangeR4Factory } from "src/lib/fixtures/factories/r4/datatypes/reference-range-r4-factory";
import { ReferenceRangeModel } from "./reference-range-model";
describe('ReferenceRangeModel', () => {
it('should create an instance', () => {
expect(new ReferenceRangeModel({})).toBeTruthy();
});
it('returns the correct display', () => {
let range = new ReferenceRangeModel(referenceRangeR4Factory.low({value: 6.2}).high({value: 8.3}).build());
let range2 = new ReferenceRangeModel(referenceRangeR4Factory.text('50.3mg/L-109.2mg/L').build());
expect(range.display()).toEqual('6.2 \u{2013} 8.3')
expect(range2.display()).toEqual('50.3mg/L-109.2mg/L')
});
describe('parsing data', () => {
it('parses high and low correctly', () => {
let range = new ReferenceRangeModel(referenceRangeR4Factory.low({value: 6.2}).high({value: 8.3}).build());
expect(range.low_value).toEqual(6.2);
expect(range.high_value).toEqual(8.3);
});
describe('when text is set', () => {
it('parses values correctly when there is a high and a low', () => {
let tests = [
{ text: '50.3-109.2', result: { low: 50.3, high: 109.2 } },
{ text: '50.3mg/L-109.2mg/L', result: { low: 50.3, high: 109.2 } },
{ text: '50.3-109.2mg/L', result: { low: 50.3, high: 109.2 } },
{ text: '50.3mg/L-109.2', result: { low: 50.3, high: 109.2 } }
]
for(let test of tests) {
let range = new ReferenceRangeModel(referenceRangeR4Factory.text(test.text).build());
expect(range.low_value).toEqual(test.result.low);
expect(range.high_value).toEqual(test.result.high);
}
});
it('parses values correctly when there is only a low', () => {
let tests = [
{ text: '>50.3', result: { low: 50.3, high: null } },
{ text: '>50.3mg/L', result: { low: 50.3, high: null } },
{ text: '>=50.3', result: { low: 50.3, high: null } },
{ text: '>=50.3mg/L', result: { low: 50.3, high: null } }
]
for(let test of tests) {
let range = new ReferenceRangeModel(referenceRangeR4Factory.text(test.text).build());
expect(range.low_value).toEqual(test.result.low);
expect(range.high_value).toEqual(test.result.high);
}
});
it('parses values correctly when there is only a high', () => {
let tests = [
{ text: '<109.2', result: { low: null, high: 109.2 } },
{ text: '<109.2mg/L', result: { low: null, high: 109.2 } },
{ text: '<=109.2', result: { low: null, high: 109.2 } },
{ text: '<=109.2mg/L', result: { low: null, high: 109.2 } }
]
for(let test of tests) {
let range = new ReferenceRangeModel(referenceRangeR4Factory.text(test.text).build());
expect(range.low_value).toEqual(test.result.low);
expect(range.high_value).toEqual(test.result.high);
}
});
});
});
});

View File

@ -0,0 +1,70 @@
import _ from "lodash";
import { ObservationValueCodeableConceptModel } from "./observation-value-codeable-concept-model";
import { QuantityModel } from "./quantity-model";
import { RangeModel } from "./range-model";
import { CodeableConcept, ObservationReferenceRange, Quantity, Range, RatioRange } from "fhir/r4";
// https://www.hl7.org/fhir/R4/observation-definitions.html#Observation.referenceRange
// Must have high or low or text
export class ReferenceRangeModel implements ObservationReferenceRange {
low?: Quantity // Simple Quantity (no comparator)
low_value?: number
high?: Quantity // Simple Quantity (no comparator)
high_value?: number
type?: CodeableConcept
appliesTo?: CodeableConcept[]
age?: RangeModel
text?: string
constructor(fhirData: any) {
this.low = new QuantityModel(_.get(fhirData, 'low'));
this.high = new QuantityModel(_.get(fhirData, 'high'));
this.type = _.get(fhirData, 'type');
this.appliesTo = _.get(fhirData, 'appliesTo');
this.age = _.get(fhirData, 'age');
this.text = _.get(fhirData, 'text');
let standardizedValues = this.chartableReferenceRange()
this.low_value = standardizedValues.low;
this.high_value = standardizedValues.high;
}
hasValue(): boolean {
return !!this.text || !!this.low_value || !!this.high_value;
}
display(): string {
return this.text || new RangeModel({low: this.low, high: this.high}).display()
}
chartableReferenceRange(): { low?: number, high?: number} {
if (this.low.value || this.high.value) {
return { low: this.low.value, high: this.high.value }
}
let matches = this.text?.match(/(?<value1>[\d.]*)?(?<operator>[^\d]*)?(?<value2>[\d.]*)?/)
if(!matches) {
return { low: null, high: null }
}
if (!!matches.groups['value1'] && !!matches.groups['value2']) {
return {
low: parseFloat(matches.groups['value1']),
high: parseFloat(matches.groups['value2'])
}
}
if (['<', '<='].includes(matches.groups['operator'])) {
return {
low: null,
high: parseFloat(matches.groups['value2'])
}
} else { // > >=
return {
low: parseFloat(matches.groups['value2']),
high: null
}
}
}
}

View File

@ -0,0 +1,54 @@
import { StringModel } from "./string-model";
describe('ObservationValueStringModel', () => {
it('should create an instance', () => {
expect(new StringModel(null)).toBeTruthy();
});
it('returns the correct visualization types', () => {
expect(new StringModel('Negative').visualizationTypes()).toEqual(['table']);
expect(new StringModel('< 10 IntlUnit/mL').visualizationTypes()).toEqual(['bar', 'table']);
});
describe('valueObject', () => {
describe('when the string contains a numerical string', () => {
it('sets value correctly', () => {
let stringValue = new StringModel('6.3 IntlUnit/mL');
let stringValue2 = new StringModel('6.3 mml/min/1.03');
expect(stringValue.valueObject()).toEqual({ value: 6.3 });
expect(stringValue2.valueObject()).toEqual({ value: 6.3 });
});
it('sets range correctly if there is a range', () => {
let stringValue = new StringModel('5 - 10 IntlUnit/mL');
let stringValue2 = new StringModel('5-10 IntlUnit/mL');
expect(stringValue.valueObject()).toEqual({ range: { low: 5, high: 10 } });
expect(stringValue2.valueObject()).toEqual({ range: { low: 5, high: 10 } });
});
it('sets range correctly if there is a comparator', () => {
let stringValue = new StringModel('< 10 IntlUnit/mL');
let stringValue2 = new StringModel('>10 IntlUnit/mL');
expect(stringValue.valueObject()).toEqual({ range: { low: null, high: 10 } });
expect(stringValue2.valueObject()).toEqual({ range: { low: 10, high: null } });
});
});
describe('when the string does not contain a numerical string', () => {
it('sets the value to the passed string', () => {
let stringValue = new StringModel('Negative');
expect(stringValue.valueObject()).toEqual({ value: 'Negative' });
});
});
});
it('returns the correct display', () => {
expect(new StringModel('Negative').display()).toEqual('Negative');
expect(new StringModel('< 10 IntlUnit/mL').display()).toEqual('< 10 IntlUnit/mL');
});
});

View File

@ -0,0 +1,60 @@
import { ObservationValue, ValueObject } from "../resources/observation-model";
export class StringModel implements ObservationValue {
sourceString: string
constructor(str: string) {
this.sourceString = str || '';
}
visualizationTypes(): string[] {
if (!!this.valueObject().range || Number.isFinite(this.valueObject().value)) {
return ['bar', 'table'];
}
return ['table'];
}
display(): string {
return this.sourceString;
}
valueObject(): ValueObject {
let matches = this.sourceString?.match(/(?<value1>[\d.]*)?(?<operator>[^\d]*)?(?<value2>[\d.]*)?/)
switch (matches.groups['operator']?.trim()) {
case '<':
case '<=':
return {
range: {
low: null,
high: parseFloat(matches.groups['value2'])
}
}
case '>':
case '>=':
return {
range: {
low: parseFloat(matches.groups['value2']),
high: null
}
}
case '-':
return {
range: {
low: parseFloat(matches.groups['value1']),
high: parseFloat(matches.groups['value2'])
}
}
}
let float = parseFloat(matches.groups['value1']);
if (Number.isNaN(float)) {
return { value: this.sourceString }
}
return { value: float };
}
}

View File

@ -1,125 +1,62 @@
import { ObservationModel } from './observation-model';
import { fhirVersions } from '../constants';
import { observationR4Factory } from 'src/lib/fixtures/factories/r4/resources/observation-r4-factory';
import { QuantityModel } from '../datatypes/quantity-model';
import { StringModel } from '../datatypes/string-model';
import { IntegerModel } from '../datatypes/integer-model';
import { BooleanModel } from '../datatypes/boolean-model';
import { ObservationValueCodeableConceptModel } from '../datatypes/observation-value-codeable-concept-model';
import { ReferenceRangeModel } from '../datatypes/reference-range-model';
import { DataAbsentReasonModel } from '../datatypes/data-absent-reason-model';
describe('ObservationModel', () => {
it('should create an instance', () => {
expect(new ObservationModel({})).toBeTruthy();
});
describe('parsing value', () => {
it('reads from valueQuantity.value if set', () => {
let observation = new ObservationModel(observationR4Factory.build(), fhirVersions.R4);
expect(observation.value_object.value).toEqual(6.3);
});
it('parses valueString correctly when value is a number if valueQuantity.value not set', () => {
let observation = new ObservationModel(observationR4Factory.valueString().build(), fhirVersions.R4);
expect(observation.value_object.value).toEqual(5.5);
});
it('parses value correctly when valueQuantity.comparator is set', () => {
let observation = new ObservationModel(observationR4Factory.valueQuantity({ comparator: '<', value: 8 }).build(), fhirVersions.R4);
let observation2 = new ObservationModel(observationR4Factory.valueQuantity({ comparator: '>', value: 8 }).build(), fhirVersions.R4);
expect(observation.value_object).toEqual({ range: { low: null, high: 8 } });
expect(observation2.value_object).toEqual({ range: { low: 8, high: null } });
});
it('parses value correctly when valueString has a range', () => {
let observation = new ObservationModel(observationR4Factory.valueString('<10 IntlUnit/mL').build(), fhirVersions.R4);
let observation2 = new ObservationModel(observationR4Factory.valueString('>10 IntlUnit/mL').build(), fhirVersions.R4);
expect(observation.value_object).toEqual({ range: { low: null, high: 10 } });
expect(observation2.value_object).toEqual({ range: { low: 10, high: null } });
});
// following two tests being kept temporarily. will be removed in next PR when I remove value_quantity_value
it('reads from valueQuantity.value if set', () => {
let observation = new ObservationModel(observationR4Factory.build(), fhirVersions.R4);
expect(observation.value_quantity_value).toEqual(6.3);
});
it('parses valueString correctly when value is a number if valueQuantity.value not set', () => {
let observation = new ObservationModel(observationR4Factory.valueString().build(), fhirVersions.R4);
expect(observation.value_quantity_value).toEqual(5.5);
});
it('sets reference_range', () => {
expect(new ObservationModel({}).reference_range).toBeInstanceOf(ReferenceRangeModel);
});
describe('parsing unit', () => {
it('reads from valueQuantity.unit if set', () => {
let observation = new ObservationModel(observationR4Factory.build(), fhirVersions.R4);
expect(observation.value_quantity_unit).toEqual('mmol/l');
describe('value_model', () => {
it('is null if there is no value setting', () => {
expect(new ObservationModel({}).value_model).toBeFalsy();
});
it('reads from valueString if valueQuantity.unit not set', () => {
it('is a QuantityModel if valueQuantity is set', () => {
let observation = new ObservationModel(observationR4Factory.valueQuantity().build(), fhirVersions.R4);
expect(observation.value_model).toBeInstanceOf(QuantityModel);
});
it('is a ObservationValueStringModel if valueString is set', () => {
let observation = new ObservationModel(observationR4Factory.valueString().build(), fhirVersions.R4);
expect(observation.value_quantity_unit).toEqual('mmol/l');
});
});
describe('parsing reference range', () => {
it('parses referenceRange correctly when high and low are not set', () => {
let observation = new ObservationModel(observationR4Factory.build(), fhirVersions.R4);
expect(observation.reference_range).toEqual({ low: null, high: null });
expect(observation.value_model).toBeInstanceOf(StringModel);
});
it('parses referenceRange correctly when high and low are set', () => {
let observation = new ObservationModel(observationR4Factory.referenceRange().build(), fhirVersions.R4);
it('is a ObservationValueIntegerModel if valueInteger is set', () => {
let observation = new ObservationModel(observationR4Factory.valueInteger().build(), fhirVersions.R4);
expect(observation.reference_range).toEqual({ low: 3.1, high: 6.5 });
expect(observation.value_model).toBeInstanceOf(IntegerModel);
});
describe('when referenceRange.text is set', () => {
it('parses values correctly when there is a high and a low', () => {
let tests = [
{ text: '50.3-109.2', result: { low: 50.3, high: 109.2 } },
{ text: '50.3mg/L-109.2mg/L', result: { low: 50.3, high: 109.2 } },
{ text: '50.3-109.2mg/L', result: { low: 50.3, high: 109.2 } },
{ text: '50.3mg/L-109.2', result: { low: 50.3, high: 109.2 } }
]
it('is a ObservationValueBooleanModel if valueBoolean is set', () => {
let observation = new ObservationModel(observationR4Factory.valueBoolean().build(), fhirVersions.R4);
for(let test of tests) {
let observation = new ObservationModel(observationR4Factory.referenceRangeString(test.text).build(), fhirVersions.R4);
expect(observation.reference_range).toEqual(test.result)
}
});
expect(observation.value_model).toBeInstanceOf(BooleanModel);
});
it('parses values correctly when there is only a low', () => {
let tests = [
{ text: '>50.3', result: { low: 50.3, high: null } },
{ text: '>50.3mg/L', result: { low: 50.3, high: null } },
{ text: '>=50.3', result: { low: 50.3, high: null } },
{ text: '>=50.3mg/L', result: { low: 50.3, high: null } }
]
it('is a ObservationValueCodeableConceptModel if valueCodeableConcept is set', () => {
let observation = new ObservationModel(observationR4Factory.valueCodeableConcept().build(), fhirVersions.R4);
for(let test of tests) {
let observation = new ObservationModel(observationR4Factory.referenceRangeStringOnlyLow(test.text).build(), fhirVersions.R4);
expect(observation.reference_range).toEqual(test.result)
}
});
expect(observation.value_model).toBeInstanceOf(ObservationValueCodeableConceptModel);
});
it('parses values correctly when there is only a high', () => {
let tests = [
{ text: '<109.2', result: { low: null, high: 109.2 } },
{ text: '<109.2mg/L', result: { low: null, high: 109.2 } },
{ text: '<=109.2', result: { low: null, high: 109.2 } },
{ text: '<=109.2mg/L', result: { low: null, high: 109.2 } }
]
it('is a ObservationValueDataAbsentReasonModel if dataAbsentReason is set', () => {
let observation = new ObservationModel(observationR4Factory.dataAbsent().build(), fhirVersions.R4);
for(let test of tests) {
let observation = new ObservationModel(observationR4Factory.referenceRangeStringOnlyHigh(test.text).build(), fhirVersions.R4);
expect(observation.reference_range).toEqual(test.result)
}
});
expect(observation.value_model).toBeInstanceOf(DataAbsentReasonModel);
});
});
});

View File

@ -1,203 +1,61 @@
import {fhirVersions, ResourceType} from '../constants';
import * as _ from "lodash";
import _ from "lodash";
import {CodableConceptModel} from '../datatypes/codable-concept-model';
import {ReferenceModel} from '../datatypes/reference-model';
import {FastenDisplayModel} from '../fasten/fasten-display-model';
import {FastenOptions} from '../fasten/fasten-options';
import { QuantityModel } from '../datatypes/quantity-model';
import { StringModel } from '../datatypes/string-model';
import { IntegerModel } from '../datatypes/integer-model';
import { BooleanModel } from '../datatypes/boolean-model';
import { ObservationValueCodeableConceptModel } from '../datatypes/observation-value-codeable-concept-model';
import { ReferenceRangeModel } from '../datatypes/reference-range-model';
import { DataAbsentReasonModel } from '../datatypes/data-absent-reason-model';
interface referenceRangeHash {
low: number | null,
high: number | null
}
// should have one or the other
// should have either range or value
export interface ValueObject {
range?: { low?: number | null, high?: number | null }
value?: number | string | boolean | null
}
export interface ObservationValue {
display(): string
visualizationTypes(): string[]
valueObject(): ValueObject
}
// https://www.hl7.org/fhir/R4/observation.html
export class ObservationModel extends FastenDisplayModel {
code: CodableConceptModel | undefined
effective_date: string
code_coding_display: string
code_text: string
value_object: ValueObject
value_quantity_value
value_quantity_unit: string
status: string
value_codeable_concept_text: string
value_codeable_concept_coding_display: string
value_codeable_concept_coding: string
value_quantity_value_number: number
subject: ReferenceModel | undefined
fhirResource: any
reference_range: referenceRangeHash
reference_range: ReferenceRangeModel
value_model: ObservationValue
constructor(fhirResource: any, fhirVersion?: fhirVersions, fastenOptions?: FastenOptions) {
super(fastenOptions)
this.fhirResource = fhirResource
this.source_resource_type = ResourceType.Observation
this.effective_date = _.get(fhirResource, 'effectiveDateTime');
this.code = _.get(fhirResource, 'code');
this.code = new CodableConceptModel(_.get(fhirResource, 'code'));
this.code_coding_display = _.get(fhirResource, 'code.coding.0.display');
this.code_text = _.get(fhirResource, 'code.text', '');
this.value_object = this.parseValue();
this.value_quantity_value = this.value_object?.value;
this.value_quantity_unit = this.parseUnit();
this.status = _.get(fhirResource, 'status', '');
this.value_codeable_concept_text = _.get(
fhirResource,
'valueCodeableConcept.text',
);
this.value_codeable_concept_coding_display = _.get(
fhirResource,
'valueCodeableConcept.coding[0].display',
);
this.value_codeable_concept_coding = _.get(
fhirResource,
'valueCodeableConcept.coding',
[],
);
this.reference_range = this.parseReferenceRange();
this.subject = _.get(fhirResource, 'subject');
}
this.reference_range = new ReferenceRangeModel(_.get(this.fhirResource, 'referenceRange.0'))
private parseValue(): ValueObject {
return this.parseValueQuantity() || this.parseValueString()
}
private parseUnit(): string {
return this.valueUnit() || this.valueStringUnit()
}
// Look for the observation's numeric value. Use this first before valueString which is a backup if this can't be found.
private parseValueQuantity(): ValueObject {
let quantity = _.get(this.fhirResource, "valueQuantity");
if (!quantity) {
return null;
}
switch (quantity.comparator) {
case '<':
case '<=':
return { range: { low: null, high: quantity.value } };
case '>':
case '>=':
return { range: { low: quantity.value, high: null } };
default:
return { value: quantity.value }
}
}
// Look for the observation's numeric value. Use this first before valueStringUnit which is a backup if this can't be found.
private valueUnit(): string {
return _.get(this.fhirResource, "valueQuantity.unit");
}
private parseValueString(): ValueObject {
let matches = _.get(this.fhirResource, "valueString")?.match(/(?<value1>[\d.]*)?(?<operator>[^\d]*)?(?<value2>[\d.]*)?/)
if(!matches) {
return { range: { low: null, high: null } }
}
if (!!matches.groups['value1'] && !!matches.groups['value2']) {
return {
range: {
low: parseFloat(matches.groups['value1']),
high: parseFloat(matches.groups['value2'])
}
}
}
if (['<', '<='].includes(matches.groups['operator'])) {
return {
range: {
low: null,
high: parseFloat(matches.groups['value2'])
}
}
} else if (['>', '>='].includes(matches.groups['operator'])) {
return {
range: {
low: parseFloat(matches.groups['value2']),
high: null
}
}
}
let float = parseFloat(matches.groups['value1']);
if (Number.isNaN(float)) {
return { value: matches.groups['value1'] }
}
return { value: float };
}
// Use if valueUnit can't be found.
private valueStringUnit(): string {
return _.get(this.fhirResource, "valueString")?.match(/(?<value>[\d.]*)(?<text>.*)/).groups.text;
}
private referenceRangeFromString(str: string): referenceRangeHash {
let matches = str?.match(/(?<value1>[\d.]*)?(?<operator>[^\d]*)?(?<value2>[\d.]*)?/)
if(!matches) {
return { low: null, high: null }
}
if (!!matches.groups['value1'] && !!matches.groups['value2']) {
return {
low: parseFloat(matches.groups['value1']),
high: parseFloat(matches.groups['value2'])
}
}
if (['<', '<='].includes(matches.groups['operator'])) {
return {
low: null,
high: parseFloat(matches.groups['value2'])
}
} else { // > >=
return {
low: parseFloat(matches.groups['value2']),
high: null
}
}
}
private parseReferenceRange(): referenceRangeHash {
let refRangeObject = _.get(this.fhirResource, "referenceRange.0")
if (refRangeObject?.low || refRangeObject?.high) {
return {
low: refRangeObject.low?.value,
high: refRangeObject.high?.value
}
}
return this.referenceRangeFromString(refRangeObject?.text)
}
public referenceRangeDisplay(): string {
// If text was sent just show it since we aren't storing difference between <= and <.
// Likely doesn't really matter, but might as well if we have that data.
if (_.get(this.fhirResource, 'referenceRange.0.text')) {
return _.get(this.fhirResource, 'referenceRange.0.text');
}
let refRange = this.parseReferenceRange()
if (refRange['low'] && refRange['high']) {
return `${refRange['low']}\u{2013}${refRange['high']}`;
} else if (refRange['low']) {
return `> ${refRange['low']}`;
} else if (refRange['high']) {
return `< ${refRange['high']}`;
}
return '';
// TODO: there are more value types that can be set: valueRange, valueRatio, valueSampledData, valueTime, valueDateTime, valuePeriod
// TODO: It is possible for values to be set in the Component element instead of any value component from above. Figure out what to do for that
if (_.get(fhirResource, 'valueQuantity')) { this.value_model = new QuantityModel(fhirResource['valueQuantity']) }
if (_.get(fhirResource, 'valueString')) { this.value_model = new StringModel(fhirResource['valueString']) }
if (_.get(fhirResource, 'valueInteger')) { this.value_model = new IntegerModel(fhirResource['valueInteger']) }
if (_.get(fhirResource, 'valueBoolean')) { this.value_model = new BooleanModel(fhirResource['valueBoolean']) }
if (_.get(fhirResource, 'valueCodeableConcept')) { this.value_model = new ObservationValueCodeableConceptModel(fhirResource['valueCodeableConcept']) }
if (_.get(fhirResource, 'dataAbsentReason')) { this.value_model = new DataAbsentReasonModel(fhirResource['dataAbsentReason']) }
}
}