import { formatDate, getAttributeValue, renderStack, renderSuperscript, TIMELINE_TRACK_HEIGHT, TimelineEvent, TimelineTrackSpecification, TimelineTrackType, ITimelineConfig, POINT_COLOR, } from 'cbioportal-clinical-timeline'; import { getEventColor, getSampleInfo, } from 'pages/patientView/timeline/TimelineWrapperUtils'; import React from 'react'; import _ from 'lodash'; import SampleMarker, { MultipleSampleMarker, } from 'pages/patientView/timeline/SampleMarker'; import SampleManager from 'pages/patientView/SampleManager'; import { ISampleMetaDeta } from 'pages/patientView/timeline/TimelineWrapper'; import { ClinicalEvent } from 'cbioportal-ts-api-client'; import { getColor } from 'cbioportal-frontend-commons'; import ReactMarkdown from 'react-markdown'; const OTHER = 'Other'; export function configureHtanOhsuTimeline(baseConfig: ITimelineConfig) { baseConfig.trackEventRenderers = baseConfig.trackEventRenderers || []; baseConfig.trackEventRenderers.push({ trackTypeMatch: /IMAGING/i, configureTrack: (cat: TimelineTrackSpecification) => { cat.renderEvents = function(e) { return ( e.stopPropagation()} > ); }; cat.renderTooltip = function(e) { return (
Click camera to open image viewer
Assay Type mIHC
File Format OME-TIFF
); }; }, }); } export function configureTimelineToxicityColors(baseConfig: ITimelineConfig) { baseConfig.trackStructures = baseConfig.trackStructures || []; baseConfig.trackStructures!.push([ 'TOXICITY', 'TOXICITY_TYPE', 'SUBTYPE', 'TOX_OTHER_SPECIFY', ]); baseConfig.eventColorGetter = function(e: TimelineEvent) { const grade = e.event.attributes.find( (att: any) => att.key === 'GRADE' ); if (grade) { const colorMap: any = { '1': 'green', '2': 'yellow', '3': 'orange', '4': 'red', }; return colorMap[grade.value] || POINT_COLOR; } else { const path = e.containingTrack.uid.split('.'); if (path[0] === 'TREATMENT' && path.length > 2) { return getColor(path[2]); } } }; } export function configureGenieTimeline(baseConfig: ITimelineConfig) { baseConfig.sortOrder = [ 'Sample acquisition', 'Sequencing', 'Surgery', 'Diagnostics', 'Diagnostic', 'Diagnosis', 'Treatment', 'Lab_test', 'Status', 'IMAGING', 'Med Onc Assessment', ]; // this differs from default in that on genie, we do NOT distinguish tracks based on subtype. we hide on subtype baseConfig.trackStructures = [ ['TREATMENT', 'TREATMENT_TYPE', 'AGENT'], ['LAB_TEST', 'TEST'], ]; // status track baseConfig.trackEventRenderers = baseConfig.trackEventRenderers || []; baseConfig.trackEventRenderers.push({ trackTypeMatch: /Med Onc Assessment|MedOnc/i, configureTrack: (cat: TimelineTrackSpecification) => { cat.label = 'Med Onc Assessment'; const _getEventColor = (event: TimelineEvent) => { if (event.end > event.start) { // range return POINT_COLOR; } return getEventColor( event, ['CURATED_CANCER_STATUS'], [ { re: /indeter/i, color: '#ffffff' }, { re: /stable/i, color: 'gainsboro' }, { re: /mixed/i, color: 'goldenrod' }, { re: /improving/i, color: 'rgb(44, 160, 44)', }, { re: /worsening/i, color: 'rgb(214, 39, 40)', }, ] ); }; cat.eventColorGetter = _getEventColor; cat.renderEvents = (events, y) => { if (events.length === 1) { const color = _getEventColor(events[0]); return ( ); } else { return null; // render default } }; }, }); // imaging track baseConfig.trackEventRenderers.push({ trackTypeMatch: /IMAGING/i, configureTrack: (cat: TimelineTrackSpecification) => { cat.label = 'Imaging Assessment'; if (cat.items && cat.items.length) { const _getEventColor = (event: TimelineEvent) => { if (event.end > event.start) { // range return POINT_COLOR; } return getEventColor( event, ['IMAGE_OVERALL', 'CURATED_CANCER_STATUS'], [ { re: /indeter|does not mention/i, color: '#ffffff', }, { re: /stable/i, color: 'gainsboro' }, { re: /mixed/i, color: 'goldenrod' }, { re: /improving/i, color: 'rgb(44, 160, 44)', }, { re: /worsening/i, color: 'rgb(214, 39, 40)', }, ] ); }; cat.eventColorGetter = _getEventColor; cat.renderEvents = (events, y) => { if (events.length === 1) { const color = _getEventColor(events[0]); return ( ); } else { return null; // use default rendering } }; } }, }); return baseConfig; } export function buildBaseConfig( sampleManager: SampleManager, caseMetaData: ISampleMetaDeta ) { let baseConfig: ITimelineConfig = { sortOrder: [ 'Specimen', 'Sequencing', 'Sample Acquisition', 'Surgery', 'Med Onc', 'Med Onc Assessment', 'Status', 'Diagnostics', 'Diagnostic', 'Imaging', 'Imaging Assessment', 'Lab_test', 'Treatment', ], trackStructures: [ ['TREATMENT', 'TREATMENT_TYPE', 'SUBTYPE', 'AGENT'], ['LAB_TEST', 'TEST'], ['DIAGNOSIS', 'SUBTYPE'], ], trackEventRenderers: [ { trackTypeMatch: /TOXICITY/, configureTrack: (cat: TimelineTrackSpecification) => {}, }, { trackTypeMatch: /LAB_TEST/i, configureTrack: (cat: TimelineTrackSpecification) => { // Configure non-PSA tracks if (cat.tracks) { cat.tracks.forEach(track => { if (track.type !== 'PSA') { configureLABTESTSubTrack(track); } }); } // Configure PSA track const psaTrack = cat.tracks ? cat.tracks.find(t => t.type === 'PSA') : undefined; if (psaTrack && psaTrack && psaTrack.items.length) { psaTrack.trackType = TimelineTrackType.LINE_CHART; psaTrack.getLineChartValue = (e: TimelineEvent) => { const val = getAttributeValue('VALUE', e) || getAttributeValue('RESULT', e); if (val === undefined) { return null; } else { return parseFloat(val.replace(/^[<>]/gi, '')); } }; } }, }, { trackTypeMatch: /^STATUS$/i, configureTrack: (cat: TimelineTrackSpecification) => { cat.renderEvents = ( events: TimelineEvent[], yCoordinate: number ) => { if ( events.length === 1 && events[0].event.attributes.find((attr: any) => { return ( !!attr.key.match(/^STATUS$/i) && !!attr.value.match(/^DECEASED$/i) ); }) ) { // if an event has a "status" attribute with value "deceased", // then render it as a black diamond const size = 7; return ( ); } else { // render default return null; } }; }, }, { trackTypeMatch: /SPECIMEN|SAMPLE ACQUISITION|SEQUENCING/i, configureTrack: (cat: TimelineTrackSpecification) => { // we want a custom tooltip for samples, which includes clinical data // not included in the timeline event cat.renderTooltip = function(event: TimelineEvent) { try { const hoveredSample = event.event.attributes.find( (att: any) => att.key === 'SAMPLE_ID' ); if (!hoveredSample || !hoveredSample.value) { return null; } const sampleWithClinicalData = sampleManager.samples.find( sample => { return sample.id === hoveredSample.value; } ); const attributes = event.event.attributes.map( attr => ({ key: attr.key, value: attr.value, }) ); // if we have clinical data, then add that in to attributes sampleWithClinicalData && sampleWithClinicalData.clinicalData.forEach( d => { attributes.push({ key: d.clinicalAttributeId, value: d.value, }); } ); // put them in order by key const orderedAttributes = _.orderBy( attributes, attr => attr.key ); return ( {sampleWithClinicalData && ( )} {orderedAttributes?.map(attr => { return ( ); })}
SAMPLE ID {sampleWithClinicalData.id}
{attr.key .toUpperCase() .replace(/_/g, ' ')} {' '} {attr.value}
START DATE {formatDate(event.start)}
); } catch (ex) { console.log( 'ERROR: Failed to render Timeline tooltip', ex ); return
Error rendering tooltip
; } }; cat.sortSimultaneousEvents = (events: TimelineEvent[]) => { return _.sortBy(events, event => { let ret = Number.POSITIVE_INFINITY; const sampleInfo = getSampleInfo( event, caseMetaData ); if (sampleInfo) { const label = parseInt(sampleInfo.label); if (!isNaN(label)) { ret = label; } } return ret; }); }; cat.renderEvents = (events: TimelineEvent[], y: number) => { if (events.length === 1) { const sampleInfo = getSampleInfo( events[0], caseMetaData ); if (sampleInfo) { return ( ); } else { return null; } } else { const colors: string[] = []; const labels: string[] = []; for (const event of events) { const sampleInfo = getSampleInfo( event, caseMetaData ); if (sampleInfo) { colors.push(sampleInfo.color); labels.push(sampleInfo.label); } } return ( ); } }; }, }, ], }; return baseConfig; } export function sortTracks( baseConfig: any, data: ClinicalEvent[] ): TimelineTrackSpecification[] { const trackStructuresByRoot = _.keyBy( baseConfig.trackStructures, arr => arr[0] ); const dataByEventType = _.groupBy(data, (e: ClinicalEvent) => e.eventType.toUpperCase() ); const allTracksInOrder: string[] = _.uniq( baseConfig.sortOrder .map((name: string) => name.toUpperCase()) .concat(Object.keys(dataByEventType)) ); const trackSpecifications = allTracksInOrder.reduce( (specs: TimelineTrackSpecification[], trackKey) => { const data = dataByEventType[trackKey]; if (!data) { return specs; } if (trackKey in trackStructuresByRoot) { specs.push( collapseOTHERTracks( organizeDataIntoTracks( trackKey, trackStructuresByRoot[trackKey].slice(1), data, trackKey ) ) ); } else { const trackSpec: Partial = { type: trackKey, uid: trackKey, }; trackSpec.items = makeItems( data, trackSpec as TimelineTrackSpecification ); specs.push(trackSpec as TimelineTrackSpecification); } return specs; }, [] ); return trackSpecifications; } function collapseOTHERTracks(rootTrack: TimelineTrackSpecification) { // In-place operation modifying the input. // Recursively find cases where there is only one child track, an Other, // and absorb its items and descendents into the parent. // If rootTrack only has one child track and it is an OTHER track, then // absorb its items and descendants into rootTrack. Keep going until // this is no longer true. while ( rootTrack.tracks && rootTrack.tracks.length === 1 && rootTrack.tracks[0].type === OTHER ) { rootTrack.items = rootTrack.items.concat(rootTrack.tracks[0].items); rootTrack.tracks = rootTrack.tracks[0].tracks; } // Recurse if (rootTrack.tracks) { rootTrack.tracks.forEach(collapseOTHERTracks); } // Finally, return the (possibly modified) argument for easy chaining return rootTrack; } function organizeDataIntoTracks( rootTrackType: string, trackStructure: string[], eventData: ClinicalEvent[], uid: string ): TimelineTrackSpecification { const dataByRootValue = _.groupBy(eventData, item => { const rootData = item.attributes.find( (att: any) => att.key === trackStructure[0] ); return rootData ? rootData.value : OTHER; }); const tracks: TimelineTrackSpecification[] = _.map( dataByRootValue, (data, rootValue) => { if (trackStructure.length > 1) { // If trackStructure is non-trivial, recurse for this rootValue const track = organizeDataIntoTracks( rootValue, trackStructure.slice(1), data, `${uid}.${rootValue}` ); return track; } else { // If trackStructure is trivial, then just return a single track for this rootValue const trackSpec: Partial = { type: rootValue, uid: `${uid}.${rootValue}`, }; trackSpec.items = makeItems( data, trackSpec as TimelineTrackSpecification ); return trackSpec as TimelineTrackSpecification; } } ); // Finally, organize all tracks under an empty root track const track = { type: rootTrackType, tracks, items: [], uid, }; return track; } function makeItems( eventData: ClinicalEvent[], containingTrack: TimelineTrackSpecification ) { return eventData.map((e: ClinicalEvent) => { return { end: e.endNumberOfDaysSinceDiagnosis || e.startNumberOfDaysSinceDiagnosis, start: e.startNumberOfDaysSinceDiagnosis, event: e, containingTrack, }; }); } function getNumericalAttrVal(name: string, e: TimelineEvent) { const val = getAttributeValue(name, e); if (val === undefined) { return null; } else { return parseFloat(val.replace(/^[<>]/gi, '')); } } function configureLABTESTSubTrack(track: TimelineTrackSpecification) { if (track.items.length) { if (allResultValuesAreNumerical(track.items)) { track.trackType = TimelineTrackType.LINE_CHART; track.getLineChartValue = e => getNumericalAttrVal('RESULT', e); } // recurse if (track.tracks) { track.tracks.forEach(configureLABTESTSubTrack); } } } export function allResultValuesAreNumerical(events: TimelineEvent[]) { return _.every(events, e => { const val = getNumericalAttrVal('RESULT', e); return !!(val !== null && !isNaN(val)); }); }