From 17dbe550beea52d7516c9ef8b572f6c991ef7ab6 Mon Sep 17 00:00:00 2001 From: alisman Date: Fri, 21 Apr 2023 13:56:35 -0400 Subject: [PATCH] Add a configurable legend to timeline tracks Remove sorting from event attributes in tooltip --- .../src/Timeline.tsx | 23 ++++- .../src/TimelineStore.tsx | 7 +- .../src/TimelineTrack.tsx | 72 +++++++++------- .../src/TimelineTracks.tsx | 84 ++++++++++++++++--- .../src/lib/helpers.spec.ts | 34 ++++++++ .../src/lib/helpers.ts | 33 ++++++++ .../src/timeline.scss | 52 +++++++++--- .../cbioportal-clinical-timeline/src/types.ts | 12 +++ .../patientView/timeline/timeline_helpers.tsx | 16 +++- 9 files changed, 274 insertions(+), 59 deletions(-) diff --git a/packages/cbioportal-clinical-timeline/src/Timeline.tsx b/packages/cbioportal-clinical-timeline/src/Timeline.tsx index 5e615685774..72ad76c0c0a 100644 --- a/packages/cbioportal-clinical-timeline/src/Timeline.tsx +++ b/packages/cbioportal-clinical-timeline/src/Timeline.tsx @@ -145,7 +145,7 @@ function handleMouseEvents( } const hoverCallback = ( - e: React.MouseEvent, + e: React.MouseEvent, styleTag: MutableRefObject, uniqueId: string ) => { @@ -168,12 +168,29 @@ const hoverCallback = ( #${uniqueId} .tl-timeline .tl-track:nth-child(${trackIndex + 1}) .tl-track-highlight { opacity: 1 !important; - } + } + + #${uniqueId} .tl-tracklegend:nth-of-type(${trackIndex + + 1}) { + display:block !important; + } + `); } break; default: - jQuery(styleTag.current!).empty(); + // when mouse is moving INTO the tooltip, we do not want to abide the + // mouseleave event. we treat it as if it's part of the track element + if ( + e.type === 'mouseleave' && + (e?.relatedTarget as Element).getAttribute('class') === + 'arrow' + ) { + break; + } else { + // get rid of the above created inline style + jQuery(styleTag.current!).empty(); + } break; } } diff --git a/packages/cbioportal-clinical-timeline/src/TimelineStore.tsx b/packages/cbioportal-clinical-timeline/src/TimelineStore.tsx index 86a98e82557..e17b4f2aff4 100644 --- a/packages/cbioportal-clinical-timeline/src/TimelineStore.tsx +++ b/packages/cbioportal-clinical-timeline/src/TimelineStore.tsx @@ -241,7 +241,12 @@ export class TimelineStore { if (content === null) { // Show default tooltip if there's no custom track tooltip renderer, // or if the track renderer returns `null` - content = ; + content = ( + + ); } const multipleItems = tooltipModel.events.length > 1; diff --git a/packages/cbioportal-clinical-timeline/src/TimelineTrack.tsx b/packages/cbioportal-clinical-timeline/src/TimelineTrack.tsx index 8ede7837359..20e06d7569f 100644 --- a/packages/cbioportal-clinical-timeline/src/TimelineTrack.tsx +++ b/packages/cbioportal-clinical-timeline/src/TimelineTrack.tsx @@ -1,9 +1,10 @@ import { EventPosition, - POINT_COLOR, + ITrackEventConfig, POINT_RADIUS, TimeLineColorGetter, TimelineEvent, + TimelineEventAttribute, TimelineTrackSpecification, TimelineTrackType, } from './types'; @@ -14,6 +15,7 @@ import { formatDate, getTrackEventCustomColorGetterFromConfiguration, REMOVE_FOR_DOWNLOAD_CLASSNAME, + segmentAndSortAttributesForTooltip, TIMELINE_TRACK_HEIGHT, } from './lib/helpers'; import { TimelineStore } from './TimelineStore'; @@ -449,47 +451,53 @@ export const OurPopup: React.FunctionComponent = observer(function( export const EventTooltipContent: React.FunctionComponent<{ event: TimelineEvent; -}> = function({ event }) { - const attributes = event.event.attributes.filter(attr => { + trackConfig: ITrackEventConfig | undefined; +}> = function({ event, trackConfig }) { + let attributes = event.event.attributes.filter(attr => { return ( attr.key !== COLOR_ATTRIBUTE_KEY && attr.key !== SHAPE_ATTRIBUTE_KEY ); }); + + // if we have an attribute order configuration, we need to + // update attribute list accordingly + if (trackConfig?.attributeOrder) { + attributes = segmentAndSortAttributesForTooltip( + attributes, + trackConfig.attributeOrder + ); + } + return (
- +
- {_.map( - attributes.sort((a: any, b: any) => - a.key > b.key ? 1 : -1 - ), - (att: any) => { - return ( - - - - - ); - } - )} + {_.map(attributes, (att: any) => { + return ( + + + + + ); + })} - {event.event.endNumberOfDaysSinceDiagnosis && ( - +
{att.key.replace(/_/g, ' ')} - ( - - ), - }} - > - {att.value} - -
{att.key.replace(/_/g, ' ')} + ( + + ), + }} + > + {att.value} + +
{`${ + {`${ event.event.endNumberOfDaysSinceDiagnosis ? 'START DATE' : 'DATE' - }`} + }`} {formatDate( event.event.startNumberOfDaysSinceDiagnosis @@ -498,7 +506,7 @@ export const EventTooltipContent: React.FunctionComponent<{
END DATEEND DATE {formatDate( event.event.endNumberOfDaysSinceDiagnosis diff --git a/packages/cbioportal-clinical-timeline/src/TimelineTracks.tsx b/packages/cbioportal-clinical-timeline/src/TimelineTracks.tsx index 4e1b8882995..7c0dc165149 100644 --- a/packages/cbioportal-clinical-timeline/src/TimelineTracks.tsx +++ b/packages/cbioportal-clinical-timeline/src/TimelineTracks.tsx @@ -10,6 +10,8 @@ import CustomTrack, { CustomTrackSpecification } from './CustomTrack'; import { TICK_AXIS_HEIGHT } from './TickAxis'; import { useObserver } from 'mobx-react-lite'; import { getBrowserWindow } from 'cbioportal-frontend-commons'; +import ReactDOM from 'react-dom'; +import { TimelineTrackSpecification } from './types'; export interface ITimelineTracks { store: TimelineStore; @@ -38,16 +40,22 @@ export const TimelineTracks: React.FunctionComponent = observer if (isTrackVisible) { return ( - + <> + + + ); } else { return null; @@ -90,6 +98,7 @@ export const TimelineTracks: React.FunctionComponent = observer transform: placementLeft ? 'translate(-100%, 0)' : '', + minWidth: 400, }} className={'tl-timeline-tooltip cbioTooltip'} positionLeft={ @@ -109,3 +118,58 @@ export const TimelineTracks: React.FunctionComponent = observer ); export default TimelineTracks; + +export const TimelineTrackLegend: React.FC<{ + y: number; + track: TimelineTrackSpecification; +}> = function({ y, track }) { + let legendEl = ; + + if (track.trackConf?.legend) { + legendEl = ( +
+ Track Legend: + + + {track.trackConf.legend.map(item => { + return ( + + + + + ); + })} + +
+ + + + {item.label}
+
+ ); + } else { + // we need to have an element for the css selector stratetgy to work + legendEl = ( +
+ ); + } + + return ReactDOM.createPortal( + legendEl, + document.getElementsByClassName('tl-timelineviewport')[0] + ); +}; diff --git a/packages/cbioportal-clinical-timeline/src/lib/helpers.spec.ts b/packages/cbioportal-clinical-timeline/src/lib/helpers.spec.ts index 1862625744c..1451d727815 100644 --- a/packages/cbioportal-clinical-timeline/src/lib/helpers.spec.ts +++ b/packages/cbioportal-clinical-timeline/src/lib/helpers.spec.ts @@ -4,6 +4,7 @@ import { formatDate, getAttributeValue, getPointInTrimmedSpace, + segmentAndSortAttributesForTooltip, } from './helpers'; import intersect from './intersect'; import { TimelineEvent } from '../types'; @@ -256,3 +257,36 @@ describe('color getter helpers', () => { ); }); }); + +describe('order attributes according to config', () => { + const atts = [ + { key: 'key1', value: 'someValue' }, + { key: 'key2', value: 'someValue' }, + { key: 'key3', value: 'someValue' }, + { key: 'key4', value: 'someValue' }, + ]; + + it('places attributes in order by config', () => { + const processedAtts = segmentAndSortAttributesForTooltip(atts, [ + 'key4', + 'key2', + ]); + + assert.deepEqual( + processedAtts.map(a => a.key), + ['key4', 'key2', 'key1', 'key3'] + ); + }); + + it('handles missing attributes in config', () => { + const processedAtts = segmentAndSortAttributesForTooltip(atts, [ + 'key10', + 'key2', + ]); + + assert.deepEqual( + processedAtts.map(a => a.key), + ['key2', 'key1', 'key3', 'key4'] + ); + }); +}); diff --git a/packages/cbioportal-clinical-timeline/src/lib/helpers.ts b/packages/cbioportal-clinical-timeline/src/lib/helpers.ts index 9f495612c4f..6a44235f159 100644 --- a/packages/cbioportal-clinical-timeline/src/lib/helpers.ts +++ b/packages/cbioportal-clinical-timeline/src/lib/helpers.ts @@ -1,7 +1,9 @@ import { POINT_COLOR, + SegmentedAttributes, TimeLineColorGetter, TimelineEvent, + TimelineEventAttribute, TimelineTick, TimelineTrackSpecification, TimelineTrackType, @@ -304,3 +306,34 @@ export const colorGetterFactory = (eventColorGetter?: TimeLineColorGetter) => { } }; }; + +export function segmentAndSortAttributesForTooltip( + attributes: TimelineEventAttribute[], + attributeOrder: string[] +) { + const segmentedAttributes = attributes.reduce( + (agg: SegmentedAttributes, att) => { + if (attributeOrder?.includes(att.key)) { + agg.first.push(att); + } else { + agg.rest.push(att); + } + return agg; + }, + { first: [], rest: [] } + ); + + // now we need to sort the first according to the provided attribute configuration + // make a map keyed by attr key for easy lookup + const attrMap = _.keyBy(segmentedAttributes.first, att => att.key); + // iterate through the configuration and get the attr object for + // each type + segmentedAttributes.first = _(attributeOrder) + .map(k => attrMap[k]) + // we need to get rid of undefined if corresponding att is missing (sometimes is) + .compact() + .value(); + + // now overwrite attributes with first followed by rest list + return segmentedAttributes.first.concat(segmentedAttributes.rest); +} diff --git a/packages/cbioportal-clinical-timeline/src/timeline.scss b/packages/cbioportal-clinical-timeline/src/timeline.scss index 5270921bc8f..3fe1f30a027 100644 --- a/packages/cbioportal-clinical-timeline/src/timeline.scss +++ b/packages/cbioportal-clinical-timeline/src/timeline.scss @@ -1,5 +1,6 @@ $width: 1200px; $borderColor: #ccc; +$tl-fontsize: 12px; .tl-timeline-wrapper { position: relative; @@ -9,7 +10,7 @@ $borderColor: #ccc; top: -25px; display: inline-block; left: 50%; - font-size: 12px; + font-size: $tl-fontsize; } } @@ -82,7 +83,7 @@ $borderColor: #ccc; overflow: hidden; display: flex; flex-direction: column; - font-size: 12px; + font-size: $tl-fontsize; > div { white-space: nowrap; border-bottom: 1px dashed #eee; @@ -121,15 +122,6 @@ $borderColor: #ccc; z-index: 1070 !important; } - //.arrow { - // border-right-color: #000000 !important; - // border-left-color: #000000 !important; - // - // &:after { - // border-right-color: #000000 !important; - // border-left-color: #000000 !important; - // } - //} .popover-content { padding: 2px 5px !important; border: none; @@ -137,6 +129,18 @@ $borderColor: #ccc; //color: #fff; font-size: 10px; + table { + vertical-align: top; + + tr:nth-child(even) { + background: #eeeeee; + } + } + + td:first-child { + font-weight: bold; + } + td, th { padding: 1px 5px; @@ -163,3 +167,29 @@ $borderColor: #ccc; .tl-custom-track-header { position: relative; } + +.tl-tracklegend { + z-index: 1000; + background: #f2f2f2; + border-radius: 4px 0 0 0; + transform: translateY(-100%); + right: 33px; + padding: 10px 15px; + font-size: $tl-fontsize; + + &.hidden { + display: none !important; + visibility: hidden; + height: 0px; + width: 0px; + } + + table { + td { + padding: 0 5px 0 0; + } + tr:last-child td { + padding-bottom: 0; + } + } +} diff --git a/packages/cbioportal-clinical-timeline/src/types.ts b/packages/cbioportal-clinical-timeline/src/types.ts index f0212705987..d67b98ba081 100644 --- a/packages/cbioportal-clinical-timeline/src/types.ts +++ b/packages/cbioportal-clinical-timeline/src/types.ts @@ -30,6 +30,13 @@ export interface ITimelineConfig { export type ITrackEventConfig = { trackTypeMatch: RegExp; configureTrack: (track: TimelineTrackSpecification) => any; + legend?: TimelineLegendItem[]; + attributeOrder?: string[]; +}; + +export type SegmentedAttributes = { + first: TimelineEventAttribute[]; + rest: TimelineEventAttribute[]; }; export enum TimelineTrackType { @@ -39,6 +46,11 @@ export enum TimelineTrackType { export type TimeLineColorGetter = (e: TimelineEvent) => string | void; +export type TimelineLegendItem = { + label: string; + color: string; +}; + export interface TimelineTrackSpecification { items: TimelineEvent[]; type: string; diff --git a/src/pages/patientView/timeline/timeline_helpers.tsx b/src/pages/patientView/timeline/timeline_helpers.tsx index 9e96ebe3360..eae98dca466 100644 --- a/src/pages/patientView/timeline/timeline_helpers.tsx +++ b/src/pages/patientView/timeline/timeline_helpers.tsx @@ -24,6 +24,7 @@ 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'; +import { TimelineLegendItem } from 'cbioportal-clinical-timeline'; const OTHER = 'Other'; @@ -145,6 +146,14 @@ export function configureGenieTimeline(baseConfig: ITimelineConfig) { 'Med Onc Assessment', ]; + const legend: TimelineLegendItem[] = [ + { label: 'Indeterminate', color: '#ffffff' }, + { label: 'Stable', color: '#dcdcdc' }, + { label: 'Mixed', color: '#daa520' }, + { label: 'Improving', color: 'rgb(44, 160, 44)' }, + { label: 'Worsening', color: 'rgb(214, 39, 40)' }, + ]; + // 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'], @@ -155,6 +164,8 @@ export function configureGenieTimeline(baseConfig: ITimelineConfig) { baseConfig.trackEventRenderers = baseConfig.trackEventRenderers || []; baseConfig.trackEventRenderers.push({ trackTypeMatch: /Med Onc Assessment|MedOnc/i, + legend, + attributeOrder: ['CURATED_CANCER_STATUS', 'CANCER_STATUS'], configureTrack: (cat: TimelineTrackSpecification) => { cat.label = 'Med Onc Assessment'; const _getEventColor = (event: TimelineEvent) => { @@ -167,7 +178,7 @@ export function configureGenieTimeline(baseConfig: ITimelineConfig) { ['CURATED_CANCER_STATUS'], [ { re: /indeter/i, color: '#ffffff' }, - { re: /stable/i, color: 'gainsboro' }, + { re: /stable/i, color: '#dcdcdc' }, { re: /mixed/i, color: 'goldenrod' }, { re: /improving/i, @@ -180,7 +191,6 @@ export function configureGenieTimeline(baseConfig: ITimelineConfig) { ] ); }; - cat.eventColorGetter = _getEventColor; cat.renderEvents = (events, y) => { if (events.length === 1) { const color = _getEventColor(events[0]); @@ -203,6 +213,8 @@ export function configureGenieTimeline(baseConfig: ITimelineConfig) { // imaging track baseConfig.trackEventRenderers.push({ trackTypeMatch: /IMAGING/i, + legend, + attributeOrder: ['CURATED_CANCER_STATUS', 'CANCER_STATUS'], configureTrack: (cat: TimelineTrackSpecification) => { cat.label = 'Imaging Assessment'; if (cat.items && cat.items.length) {