Skip to content

Commit

Permalink
Add clinical attributes styling from cBioPortal
Browse files Browse the repository at this point in the history
- Add clinicalAttributesUtil.js
    - use set innerHTML to prevent having to rewrite for JSX Element list
- Add attribute selection styling of clinical attribute spans
  • Loading branch information
inodb committed Jan 24, 2017
1 parent 8467000 commit 4d09b34
Show file tree
Hide file tree
Showing 9 changed files with 431 additions and 53 deletions.
11 changes: 7 additions & 4 deletions src/pages/patientView/PatientViewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ import FeatureTitle from '../../shared/components/featureTitle/FeatureTitle';
import renderIf from 'render-if';
import { If, Then, Else } from 'react-if';
import queryString from "query-string";
import SampleManager from './sampleManager';
import SelectCallback = ReactBootstrap.SelectCallback;
import SampleManager from './sampleManager';
import PatientHeader from './patientHeader/PatientHeader';

import './patientHeader/style/ClinicalAttributes.scss';

export interface IPatientViewPageProps {
store?: RootState;
Expand Down Expand Up @@ -155,19 +158,19 @@ export default class PatientViewPage extends React.Component<IPatientViewPagePro
public render() {

let sampleManager: SampleManager | null = null;
let sampleHeader: JSX.Element[] | null = null;
let sampleHeader: JSX.Element[] | null | undefined = null;

if (this.props.samples) {
sampleManager = new SampleManager(this.props.samples);

sampleHeader = _.map(sampleManager!.samples,(sample: ClinicalDataBySampleId) => {
return <span style={{ marginRight:10 }}>{sampleManager!.getComponentForSample(sample.id)} {sample.id}</span>;
return sampleManager!.getComponentForSample(sample.id, true);
});

}

return (
<div>
<PatientHeader patient={this.props.patient} />

<If condition={sampleHeader}>
<div style={{marginBottom:20}}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import * as $ from 'jquery';
import _ from 'underscore';

/**
* Functions for dealing with clinical attributes.
*/
/**
* Clean clinical attributes. Useful for rounding numbers, or other types of
* data cleanup steps. Probably differs per institution.
* @param {object} clinicalData - key/value pairs of clinical data
*/
function clean(clinicalData) {
// Shallow Copy clinicalData
const cleanClinicalData = $.extend({}, clinicalData);
const NULL_VALUES = [
'not applicable',
'not available',
'pending',
'discrepancy',
'completed',
'',
'null',
'unknown',
'na',
'n/a',
'[unkown]',
'[not submitted]',
'[not evaluated]',
'[not applicable]',
'[not available]',
'[undefined]'
];

const keys = Object.keys(clinicalData);
for (let i = 0; i < keys.length; i += 1) {
let value;
const key = keys[i];

value = clinicalData[key];

// Remove null values
if (NULL_VALUES.indexOf(value.toLowerCase()) > -1) {
delete cleanClinicalData[key];
} else {
// Change values for certain attributes, e.g. rounding
switch (key) {
case 'OS_MONTHS':
case 'DFS_MONTHS':
if ($.isNumeric(value)) {
value = Math.round(value);
}
cleanClinicalData[key] = value;
break;
default:
}
}
}
return cleanClinicalData;
}

/**
* Get first key found in object. Otherwise return null.
* @param {object} object - object with key/value pairs
* @param {array} keys - array of keys
*/
function getFirstKeyFound(object, keys) {
if (!object) {
return null;
}

for (let i = 0; i < keys.length; i += 1) {
const value = object[keys[i]];
if (typeof value !== 'undefined' && value !== null) {
return value;
}
}
return null;
}


/**
* Derive clinical attributes from existing clinical attributes .e.g. age based
* on a date of birth. TODO: Now only includes a funky hack to keep current
* derived clinical attributes working.
* @param {object} clinicalData - key/value pairs of clinical data
*/
function derive(clinicalData) {
const derivedClinicalAttributes = $.extend({}, clinicalData);

/**
* TODO: Pretty funky function to get a normalized case type. This should
* probably also be a clinical attribute with a restricted vocabulary. Once
* the database has been changed to include normalized case types, this
* function should be removed.
* @param {object} clinicalData - key/value pairs of clinical data
* @param {string} caseTypeAttrs - TUMOR_TYPE or SAMPLE_TYPE value to normalize
*/
function normalizedCaseType(cData, caseTypeAttrs) {
let caseTypeNormalized = null;
let caseType;
let caseTypeLower;
let i;

for (i = 0; i < caseTypeAttrs.length; i += 1) {
caseType = cData[caseTypeAttrs[i]];

if (caseType !== null && typeof caseType !== 'undefined') {
caseTypeLower = caseType.toLowerCase();

if (caseTypeLower.indexOf('metasta') >= 0) {
caseTypeNormalized = 'Metastasis';
} else if (caseTypeLower.indexOf('recurr') >= 0) {
caseTypeNormalized = 'Recurrence';
} else if (caseTypeLower.indexOf('progr') >= 0) {
caseTypeNormalized = 'Progressed';
} else if (caseTypeLower.indexOf('prim') >= 0 ||
caseTypeLower.indexOf('prim') >= 0) {
caseTypeNormalized = 'Primary';
}
if (caseTypeNormalized !== null && typeof caseTypeNormalized !== 'undefined') {
break;
}
}
}

return caseTypeNormalized;
}

const caseTypeNormalized = normalizedCaseType(clinicalData, ['SAMPLE_TYPE', 'TUMOR_TISSUE_SITE', 'TUMOR_TYPE']);
if (caseTypeNormalized !== null) {
let loc;

derivedClinicalAttributes.DERIVED_NORMALIZED_CASE_TYPE = caseTypeNormalized;

// TODO: DERIVED_SAMPLE_LOCATION should probably be a clinical attribute.
if (derivedClinicalAttributes.DERIVED_NORMALIZED_CASE_TYPE === 'Metastasis') {
loc = getFirstKeyFound(clinicalData, ['METASTATIC_SITE', 'TUMOR_SITE']);
} else if (derivedClinicalAttributes.DERIVED_NORMALIZED_CASE_TYPE === 'Primary') {
loc = getFirstKeyFound(clinicalData, ['PRIMARY_SITE', 'TUMOR_SITE']);
} else {
loc = getFirstKeyFound(clinicalData, ['TUMOR_SITE']);
}
if (loc !== null) {
derivedClinicalAttributes.DERIVED_SAMPLE_LOCATION = loc;
}
}

return derivedClinicalAttributes;
}

/**
* Run both clean and derive on the clinicalData.
*/
function cleanAndDerive(clinicalData) {
return derive(clean(clinicalData));
}

/**
* Return string of spans representing the clinical attributes. The spans
* have been made specifically to add clinical attribute information as
* attributes to allow for easy styling with CSS.
* @param {object} clinicalData - key/value pairs of clinical data
* @param {string} cancerStudyId - short name of cancer study
*/
function getSpans(clinicalData, cancerStudyId) {
let spans = '';
const clinicalAttributesCleanDerived = cleanAndDerive(clinicalData);

const keys = Object.keys(clinicalAttributesCleanDerived);
for (let i = 0; i < keys.length; i += 1) {
const key = keys[i];
const value = clinicalAttributesCleanDerived[key];
spans += `<span class="clinical-attribute" attr-id="${key}" attr-value="${value}" study="${cancerStudyId}">${value}</span>`;
}

return spans;
}

/*
* Add .first-order class to all elements with the lowest order attribute.
* This way the first element can be styled in a different manner. If flex
* order attributes were working properly in CSS, one would be able to say
* .clinical-attribute:first, this is unfortunately not the case, therefore
* this hack is required. See clinical-attributes.css to see how this is
* used.
*/
function addFirstOrderClass() {
$('.sample-record-inline, #more-patient-info').each(() => {
const orderSortedAttributes = _.sortBy($(this).find('a > .clinical-attribute'), (y) => {
const order = parseInt($(y).css('order'), 10);
if (isNaN(order)) {
console.log('Warning: No order attribute found in .clinical-attribute.');
}
return order;
});
$(orderSortedAttributes[0]).addClass('first-order');
});
}

export { cleanAndDerive, getSpans, addFirstOrderClass };
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default class TumorColumnFormatter {
return (
<li className={(sample.id in presentSamples) ? '' : 'invisible'}>
{
columnProps.sampleManager.getComponentForSample(sample.id, { showText: false })
columnProps.sampleManager.getComponentForSample(sample.id, false)
}
</li>
);
Expand Down
36 changes: 36 additions & 0 deletions src/pages/patientView/patientHeader/ClinicalAttributesInline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from 'react';
import {ClinicalData} from "../../../shared/api/CBioPortalAPI";

export type IClinicalAttributesInlineProps = {
clinicalData?: ClinicalData;
cancerStudyId: string;
};

//export default class ClinicalAttributesInline extends React.Component<IClinicalAttributesInlineProps, {}> {
// public render() {
// switch (this.props.status) {
// case 'fetching':
// return <div><Spinner spinnerName='three-bounce' /></div>;
//
// case 'complete':
// return this.draw();
//
// case 'error':
// return <div>There was an error.</div>;
//
// default:
// return <div />;
// }
// }
//}

type IClinicalAttributeProps ={
key: string;
value: string;
};

// class ClinicalAttribute extends React.Component<IClinicalAttributeProps, {}> {
// public render() {
// return <span className={`clinical-attribute`} attrId={key} attrValue={value} study={cancerStudyId}>{value}</span>;
// }
// }
39 changes: 7 additions & 32 deletions src/pages/patientView/patientHeader/PatientHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as React from 'react';
import {fromPairs} from 'lodash';
import {OverlayTrigger, Popover} from 'react-bootstrap';

import ClinicalInformationPatientTable from '../clinicalInformation/ClinicalInformationPatientTable';
import SampleInline from './SampleInline';
import {ClinicalInformationData} from "../Connector";
import { ClinicalDataBySampleId } from "../../../shared/api/api-types-extended";
import {getSpans} from '../clinicalInformation/lib/clinicalAttributesUtil.js';

import styles from './styles.module.scss';

Expand All @@ -15,24 +15,13 @@ export default class PatientHeader extends React.Component<IPatientHeaderProps,

return (
<div className={styles.patientHeader}>
<div>
<i className="fa fa-female fa-2 genderIcon hidden" aria-hidden="true"></i>
{this.props.patient && this.getOverlayTriggerPatient(this.props.patient)}
</div>
{this.props.samples && this.props.samples.map((s, n) => this.getOverlayTriggerSample(s, n))}
<i className="fa fa-female fa-2 genderIcon hidden" aria-hidden="true"></i>
{this.props.patient && this.getOverlayTriggerPatient(this.props.patient)}
</div>
);

}

private getPopoverSample(sample: ClinicalDataBySampleId, sampleNumber: number) {
return (
<Popover key={sampleNumber} id={'popover-sample-' + sampleNumber}>
<ClinicalInformationPatientTable showTitleBar={false} data={sample.clinicalData} />
</Popover>
);
}

private getPopoverPatient(patient: ClinicalInformationData['patient']) {
return patient && (
<Popover key={patient.id} id={'popover-sample-' + patient.id}>
Expand All @@ -52,25 +41,11 @@ export default class PatientHeader extends React.Component<IPatientHeaderProps,
>
<span>
{patient.id}
<span className='clinical-spans' dangerouslySetInnerHTML={{__html:
getSpans(fromPairs(patient.clinicalData.map((x) => [x.clinicalAttributeId, x.value])), 'lgg_ucsf_2014')}}>
</span>
</span>
</OverlayTrigger>
);
}

private getOverlayTriggerSample(sample: ClinicalDataBySampleId, sampleNumber: number) {
return (
<OverlayTrigger
delayHide={100}
key={sampleNumber}
trigger={['hover', 'focus']}
placement='bottom'
overlay={this.getPopoverSample(sample, sampleNumber + 1)}
>
<span>
<SampleInline sample={sample} sampleNumber={sampleNumber + 1} />
</span>
</OverlayTrigger>
);
}

}
26 changes: 22 additions & 4 deletions src/pages/patientView/patientHeader/SampleInline.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
import * as React from "react";
import {SampleLabelHTML} from "../../../shared/components/sampleLabel/SampleLabel";
import { ClinicalDataBySampleId } from "../../../shared/api/api-types-extended";
import {fromPairs} from 'lodash';
import {getSpans} from '../clinicalInformation/lib/clinicalAttributesUtil.js';

interface ISampleInlineProps {
sample: ClinicalDataBySampleId;
sampleNumber: number;
showClinical: boolean;
}

export default class SampleInline extends React.Component<ISampleInlineProps, {}> {
public render() {
const { sample, sampleNumber } = this.props;
const { sample, sampleNumber, showClinical } = this.props;

return (
<SampleLabelHTML color={'black'} label={(sampleNumber).toString()} />
);

if (showClinical) {
return (
<span style={{paddingRight: '10px'}}>
<SampleLabelHTML color={'black'} label={(sampleNumber).toString()} />
{' ' + sample.id}
<span className="clinical-spans" dangerouslySetInnerHTML={{__html:
getSpans(fromPairs(sample.clinicalData.map((x) => [x.clinicalAttributeId, x.value])), 'lgg_ucsf_2014')}}>
</span>
</span>
);
} else {
return (
<span style={{paddingRight: '10px'}}>
<SampleLabelHTML color={'black'} label={(sampleNumber).toString()} />
</span>
);
}
}
}
Loading

0 comments on commit 4d09b34

Please sign in to comment.