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 && (
| SAMPLE ID |
{sampleWithClinicalData.id}
|
)}
{orderedAttributes?.map(attr => {
return (
|
{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));
});
}