From 4d09b3473052d24d4e3ab6cc003bd5e0fce65a06 Mon Sep 17 00:00:00 2001 From: Ino de Bruijn Date: Mon, 12 Dec 2016 13:18:47 -0500 Subject: [PATCH] Add clinical attributes styling from cBioPortal - Add clinicalAttributesUtil.js - use set innerHTML to prevent having to rewrite for JSX Element list - Add attribute selection styling of clinical attribute spans --- src/pages/patientView/PatientViewPage.tsx | 11 +- .../lib/clinicalAttributesUtil.js | 200 ++++++++++++++++++ .../mutation/column/TumorColumnFormatter.tsx | 2 +- .../ClinicalAttributesInline.tsx | 36 ++++ .../patientHeader/PatientHeader.tsx | 39 +--- .../patientHeader/SampleInline.tsx | 26 ++- .../style/clinicalAttributes.scss | 145 +++++++++++++ src/pages/patientView/sampleManager.tsx | 23 +- .../components/sampleLabel/SampleLabel.tsx | 2 +- 9 files changed, 431 insertions(+), 53 deletions(-) create mode 100644 src/pages/patientView/clinicalInformation/lib/clinicalAttributesUtil.js create mode 100644 src/pages/patientView/patientHeader/ClinicalAttributesInline.tsx create mode 100644 src/pages/patientView/patientHeader/style/clinicalAttributes.scss diff --git a/src/pages/patientView/PatientViewPage.tsx b/src/pages/patientView/PatientViewPage.tsx index a14d8a49af1..f373d122310 100644 --- a/src/pages/patientView/PatientViewPage.tsx +++ b/src/pages/patientView/PatientViewPage.tsx @@ -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; @@ -155,19 +158,19 @@ export default class PatientViewPage extends React.Component { - return {sampleManager!.getComponentForSample(sample.id)} {sample.id}; + return sampleManager!.getComponentForSample(sample.id, true); }); - } return (
+
diff --git a/src/pages/patientView/clinicalInformation/lib/clinicalAttributesUtil.js b/src/pages/patientView/clinicalInformation/lib/clinicalAttributesUtil.js new file mode 100644 index 00000000000..a8b1c8563bf --- /dev/null +++ b/src/pages/patientView/clinicalInformation/lib/clinicalAttributesUtil.js @@ -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 += `${value}`; + } + + 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 }; diff --git a/src/pages/patientView/mutation/column/TumorColumnFormatter.tsx b/src/pages/patientView/mutation/column/TumorColumnFormatter.tsx index 71f76ac5f0e..8e1105c86be 100644 --- a/src/pages/patientView/mutation/column/TumorColumnFormatter.tsx +++ b/src/pages/patientView/mutation/column/TumorColumnFormatter.tsx @@ -19,7 +19,7 @@ export default class TumorColumnFormatter { return (
  • { - columnProps.sampleManager.getComponentForSample(sample.id, { showText: false }) + columnProps.sampleManager.getComponentForSample(sample.id, false) }
  • ); diff --git a/src/pages/patientView/patientHeader/ClinicalAttributesInline.tsx b/src/pages/patientView/patientHeader/ClinicalAttributesInline.tsx new file mode 100644 index 00000000000..1e040d64237 --- /dev/null +++ b/src/pages/patientView/patientHeader/ClinicalAttributesInline.tsx @@ -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 { +// public render() { +// switch (this.props.status) { +// case 'fetching': +// return
    ; +// +// case 'complete': +// return this.draw(); +// +// case 'error': +// return
    There was an error.
    ; +// +// default: +// return
    ; +// } +// } +//} + +type IClinicalAttributeProps ={ + key: string; + value: string; +}; + +// class ClinicalAttribute extends React.Component { +// public render() { +// return {value}; +// } +// } diff --git a/src/pages/patientView/patientHeader/PatientHeader.tsx b/src/pages/patientView/patientHeader/PatientHeader.tsx index 78e73a3506a..dc9617bfb26 100644 --- a/src/pages/patientView/patientHeader/PatientHeader.tsx +++ b/src/pages/patientView/patientHeader/PatientHeader.tsx @@ -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'; @@ -15,24 +15,13 @@ export default class PatientHeader extends React.Component -
    - - {this.props.patient && this.getOverlayTriggerPatient(this.props.patient)} -
    - {this.props.samples && this.props.samples.map((s, n) => this.getOverlayTriggerSample(s, n))} + + {this.props.patient && this.getOverlayTriggerPatient(this.props.patient)}
    ); } - private getPopoverSample(sample: ClinicalDataBySampleId, sampleNumber: number) { - return ( - - - - ); - } - private getPopoverPatient(patient: ClinicalInformationData['patient']) { return patient && ( @@ -52,25 +41,11 @@ export default class PatientHeader extends React.Component {patient.id} + [x.clinicalAttributeId, x.value])), 'lgg_ucsf_2014')}}> + ); } - - private getOverlayTriggerSample(sample: ClinicalDataBySampleId, sampleNumber: number) { - return ( - - - - - - ); - } - } diff --git a/src/pages/patientView/patientHeader/SampleInline.tsx b/src/pages/patientView/patientHeader/SampleInline.tsx index d0d5e11a840..3055476b805 100644 --- a/src/pages/patientView/patientHeader/SampleInline.tsx +++ b/src/pages/patientView/patientHeader/SampleInline.tsx @@ -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 { public render() { - const { sample, sampleNumber } = this.props; + const { sample, sampleNumber, showClinical } = this.props; - return ( - - ); + + if (showClinical) { + return ( + + + {' ' + sample.id} + [x.clinicalAttributeId, x.value])), 'lgg_ucsf_2014')}}> + + + ); + } else { + return ( + + + + ); + } } } diff --git a/src/pages/patientView/patientHeader/style/clinicalAttributes.scss b/src/pages/patientView/patientHeader/style/clinicalAttributes.scss new file mode 100644 index 00000000000..cf09bf19143 --- /dev/null +++ b/src/pages/patientView/patientHeader/style/clinicalAttributes.scss @@ -0,0 +1,145 @@ +/* Do not display clinical attributes on default */ +.clinical-spans { + .clinical-attribute { + display: none; + order: 999; + } + /* Show only following attributes */ + .clinical-attribute[attr-id="SEX"], + .clinical-attribute[attr-id="GENDER"], + .clinical-attribute[attr-id="AGE"], + .clinical-attribute[attr-id="OS_STATUS"], + .clinical-attribute[attr-id="OS_MONTHS"], + .clinical-attribute[attr-id="DFS_STATUS"], + .clinical-attribute[attr-id="DFS_MONTHS"], + .clinical-attribute[attr-id="CANCER_TYPE"], + #more-patient-info .clinical-attribute[attr-id="CANCER_TYPE_DETAILED"], + .clinical-attribute[attr-id="KNOWN_MOLECULAR_CLASSIFIER"], + .clinical-attribute[attr-id="GLEASON_SCORE"], + .clinical-attribute[attr-id="HISTOLOGY"], + .clinical-attribute[attr-id="TUMOR_STAGE_2009"], + .clinical-attribute[attr-id="TUMOR_GRADE"], + .clinical-attribute[attr-id="ETS_RAF_SPINK1_STATUS"], + .clinical-attribute[attr-id="TMPRSS2_ERG_FUSION_STATUS"], + .clinical-attribute[attr-id="ERG_FUSION_ACGH"], + .clinical-attribute[attr-id="SERUM_PSA"], + .clinical-attribute[attr-id="DRIVER_MUTATIONS"], + .clinical-attribute[attr-id="SAMPLE_CLASS"], + .clinical-attribute[attr-id="DERIVED_NORMALIZED_CASE_TYPE"], + .clinical-attribute[attr-id="DERIVED_SAMPLE_LOCATION"] { + display: inline; + order: 6; + } + /* Hide following attributes for samples */ + /* Show comma before clinical attributes, except first one */ + .clinical-attribute:not(.first-order):before { + content: ", "; + color: #428bca; + } + /* Order clinical attributes */ + /* Order sample+patient clinical attributes */ + .clinical-attribute[attr-id="PATIENT_DISPLAY_NAME"], + .clinical-attribute[attr-id="SAMPLE_DISPLAY_NAME"] { + order: 0; + } + /* Order patient clinical attributes */ + #more-patient-info .clinical-attribute[attr-id="SEX"], + #more-patient-info .clinical-attribute[attr-id="GENDER"] { + order: 1; + } + #more-patient-info .clinical-attribute[attr-id="AGE"] { + order: 2; + } + #more-patient-info .clinical-attribute[attr-id="CANCER_TYPE"] { + order: 3; + } + #more-patient-info .clinical-attribute[attr-id="CANCER_TYPE_DETAILED"] { + order: 4; + } + #more-patient-info .clinical-attribute[attr-id="KNOWN_MOLECULAR_CLASSIFIER"] { + order: 5; + } + #more-patient-info .clinical-attribute[attr-id="HISTOLOGY"] { + order: 6; + } + #more-patient-info .clinical-attribute[attr-id="OS_STATUS"] { + order: 7; + } + #more-patient-info .clinical-attribute[attr-id="OS_MONTHS"] { + order: 8; + } + #more-patient-info .clinical-attribute[attr-id="DFS_STATUS"] { + order: 9; + } + #more-patient-info .clinical-attribute[attr-id="DFS_MONTHS"] { + order: 10; + } + /* Order sample clinical attributes */ + .more-sample-info .clinical-attribute[attr-id="DERIVED_NORMALIZED_CASE_TYPE"] { + order: 1; + text-transform: capitalize; + } + .more-sample-info .clinical-attribute[attr-id="DERIVED_SAMPLE_LOCATION"] { + order: 2; + } + /* attributes with opening parenthesis */ + .clinical-attribute[attr-id="OS_MONTHS"]:before, + .clinical-attribute[attr-id="DFS_MONTHS"]:before, + .clinical-attribute[attr-id="CANCER_TYPE_DETAILED"]:before, + .clinical-attribute[attr-id="PATIENT_DISPLAY_NAME"]:before, + .clinical-attribute[attr-id="DERIVED_SAMPLE_LOCATION"]:before, + .clinical-attribute[attr-id="SAMPLE_DISPLAY_NAME"]:before { + content: "\00a0("; + color: #428bca; + } + /* attributes with a closing parenthesis */ + .clinical-attribute[attr-id="CANCER_TYPE_DETAILED"]:after, + .clinical-attribute[attr-id="PATIENT_DISPLAY_NAME"]:after, + .clinical-attribute[attr-id="DERIVED_SAMPLE_LOCATION"]:after, + .clinical-attribute[attr-id="SAMPLE_DISPLAY_NAME"]:after { + content: ")"; + } + /* text before an attribute */ + .clinical-attribute[attr-id="SERUM_PSA"]:before { + content: ", Serum PSA: "; + } + .clinical-attribute[attr-id="ERG_FUSION_ACGH"]:before { + content: ", ERG-fusion aCGH: "; + } + .clinical-attribute[attr-id="TMPRSS2_ERG_FUSION_STATUS"]:before { + content: ", TMPRSS2-ERG Fusion: "; + } + .clinical-attribute[attr-id="GLEASON"]:before { + content: ", Gleason: "; + } + /* text after an attribute */ + .clinical-attribute[attr-id="OS_MONTHS"]:after, + .clinical-attribute[attr-id="DFS_MONTHS"]:after { + content: " months)"; + } + .clinical-attribute[attr-id="AGE"]:after { + content: " years old"; + } + /* attributes with special colors */ + .clinical-attribute[attr-id="OS_STATUS"][attr-value="DECEASED"], + .clinical-attribute[attr-id="OS_STATUS"][attr-value="DEAD"], + .clinical-attribute[attr-id="DFS_STATUS"] { + color: #f00; + } + .clinical-attribute[attr-id="OS_STATUS"][attr-value="LIVING"], + .clinical-attribute[attr-id="OS_STATUS"][attr-value="ALIVE"], + .clinical-attribute[attr-id="DFS_STATUS"][attr-value="DiseaseFree"], + .clinical-attribute[attr-id="DFS_STATUS"][attr-value="Yes"] { + color: rgb(0, 128, 0); + } + .clinical-attribute[attr-id="DERIVED_NORMALIZED_CASE_TYPE"][attr-value='Primary'] { + color: black; + } + .clinical-attribute[attr-id="DERIVED_NORMALIZED_CASE_TYPE"][attr-value="Progressed"], + .clinical-attribute[attr-id="DERIVED_NORMALIZED_CASE_TYPE"][attr-value="Recurrence"] { + color: orange; + } + .clinical-attribute[attr-id="DERIVED_NORMALIZED_CASE_TYPE"][attr-value="Metastasis"] { + color: red; + } +} diff --git a/src/pages/patientView/sampleManager.tsx b/src/pages/patientView/sampleManager.tsx index 0f72865b831..3ae6290bb19 100644 --- a/src/pages/patientView/sampleManager.tsx +++ b/src/pages/patientView/sampleManager.tsx @@ -19,20 +19,22 @@ class SampleManager { }); } - getComponentForSample(sampleId: string, options?: { showText:Boolean }) { + getComponentForSample(sampleId: string, showClinical = false) { - let sample = _.find(this.samples, (sample: ClinicalDataBySampleId)=>{ - return sample.id === sampleId; + let sample = _.find(this.samples, (s: ClinicalDataBySampleId)=> { + return s.id === sampleId; }); - return sample && this.getOverlayTriggerSample(sample, this.sampleIndex[sample.id]); + console.log(sample); + return sample && this.getOverlayTriggerSample(sample, this.sampleIndex[sample.id], showClinical); + } getComponentsForSamples() { this.samples.map((sample)=>this.getComponentForSample(sample.id)); } - getOverlayTriggerSample(sample: ClinicalDataBySampleId, sampleIndex: number) { + getOverlayTriggerSample(sample: ClinicalDataBySampleId, sampleIndex: number, showClinical = false) { let sampleNumberText: number = sampleIndex+1; @@ -44,12 +46,11 @@ class SampleManager { placement='bottom' overlay={this.getPopoverSample(sample, sampleNumberText)} > - - - + ); } diff --git a/src/shared/components/sampleLabel/SampleLabel.tsx b/src/shared/components/sampleLabel/SampleLabel.tsx index 585714f3db2..53687247c7e 100644 --- a/src/shared/components/sampleLabel/SampleLabel.tsx +++ b/src/shared/components/sampleLabel/SampleLabel.tsx @@ -33,7 +33,7 @@ export class SampleLabelHTML extends React.Component public render() { const { label, color } = this.props; return ( - + {label}