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:
parent
1bcf4aaf7e
commit
62644155c4
|
@ -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])
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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]}`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)]
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
tbody tr td:first-child {
|
||||
font-weight: bold;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,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)]
|
||||
}
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
<observation-bar-chart *ngIf="visualizationType == 'bar'" [observations]="observations"></observation-bar-chart>
|
||||
<observation-table *ngIf="visualizationType == 'table'" [observations]="observations"></observation-table>
|
|
@ -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');
|
||||
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)]
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
.visualization-container {
|
||||
max-height: 20em;
|
||||
display: inline-block;
|
||||
overflow: scroll;
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
));
|
|
@ -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(() => (
|
||||
{}
|
||||
));
|
|
@ -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'
|
||||
}
|
||||
));
|
|
@ -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: [
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
}
|
||||
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -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 } });
|
||||
});
|
||||
});
|
||||
|
||||
});
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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('')
|
||||
});
|
||||
})
|
||||
|
||||
});
|
|
@ -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 '';
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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']) }
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue