import { EventPosition, POINT_COLOR, POINT_RADIUS, TimeLineColorGetter, TimelineEvent, TimelineTrackSpecification, TimelineTrackType, } from './types'; import React, { useCallback, useState } from 'react'; import _ from 'lodash'; import { colorGetterFactory, formatDate, getTrackEventCustomColorGetterFromConfiguration, REMOVE_FOR_DOWNLOAD_CLASSNAME, TIMELINE_TRACK_HEIGHT, } from './lib/helpers'; import { TimelineStore } from './TimelineStore'; import { renderStack } from './svg/renderStack'; import { observer } from 'mobx-react'; import { getLineChartYCoordinateForEvents, getTicksForLineChartAxis, getTrackValueRange, } from './lib/lineChartAxisUtils'; import { getBrowserWindow, getColor } from 'cbioportal-frontend-commons'; import { getTrackLabel } from './TrackHeader'; import { COLOR_ATTRIBUTE_KEY, renderShape, SHAPE_ATTRIBUTE_KEY, } from './renderHelpers'; import ReactMarkdown from 'react-markdown'; import { useLocalObservable, useLocalStore } from 'mobx-react-lite'; export interface ITimelineTrackProps { trackData: TimelineTrackSpecification; limit: number; getPosition: ( item: TimelineEvent, limit: number ) => EventPosition | undefined; handleTrackHover: (e: React.MouseEvent) => void; store: TimelineStore; y: number; height: number; width: number; } /* get events with identical positions so we can stack them */ export function groupEventsByPosition(events: TimelineEvent[]) { return _.groupBy(events, e => { return `${e.start}-${e.end}`; }); } export function renderSuperscript(number: number, y: number = 0) { return ( {number} ); } function renderTickGridLines(track: TimelineTrackSpecification, width: number) { const ticks = getTicksForLineChartAxis(track); return ticks.map(tick => ( )); } function renderLineChartConnectingLines(points: { x: number; y: number }[]) { if (points.length < 2) { return null; } return ( {points.map((point, index) => { if (index === 0) { return null; } else { const prev = points[index - 1]; return ( ); } })} ); } function getPointY( events: TimelineEvent[], trackData: TimelineTrackSpecification, trackHeight: number, trackValueRange?: { min: number; max: number } ) { let y: number | null; if (!trackValueRange) { y = trackHeight / 2; } else { y = getLineChartYCoordinateForEvents( events, trackData, trackHeight, trackValueRange! ); } return y; } export function randomColorGetter(e: TimelineEvent) { return getColor(getTrackLabel(e.containingTrack)); } export function renderPoint( events: TimelineEvent[], y: number, eventColorGetter?: TimeLineColorGetter ) { // When nested tracks are collapsed, we might see multiple events that are // from different tracks. So let's check if all these events actually come // from the same track const allFromSameTrack = _.uniq(events.map(e => e.containingTrack.uid)).length === 1; let contents: any | null = null; if (allFromSameTrack && events[0].containingTrack.renderEvents) { // If they are all from the same track and there is a track-specific renderer, // try that. contents = events[0].containingTrack.renderEvents(events, y); } // Otherwise, or if that returns null, use default renderers. For multiple events, we'll show the individual // renders in the tooltip. if (contents === null) { if (events.length > 1) { contents = ( <> {renderSuperscript(events.length, y)} {renderStack( events.map(colorGetterFactory(eventColorGetter)), y )} ); } else { contents = renderShape( events[0], y, colorGetterFactory(eventColorGetter) ); } } return {contents}; } function renderRange( pixelWidth: number, events: TimelineEvent[], eventColorGetter?: TimeLineColorGetter ) { const height = 5; return ( ); } export const TimelineTrack: React.FunctionComponent = observer( function({ trackData, limit, getPosition, handleTrackHover, store, y, height, width, }: ITimelineTrackProps) { let eventsGroupedByPosition; if (trackData.items) { // group events which occur on the same day offset // so they can be "stacked" eventsGroupedByPosition = groupEventsByPosition(trackData.items); // if this track has a custom sorting function // configured for simultaneous events, employ it if (trackData.sortSimultaneousEvents) { eventsGroupedByPosition = _.mapValues( eventsGroupedByPosition, trackData.sortSimultaneousEvents ); } } let trackValueRange: { min: number; max: number }; const linePoints: { x: number; y: number }[] = []; if (trackData.trackType === TimelineTrackType.LINE_CHART) { trackValueRange = getTrackValueRange(trackData); } const points = eventsGroupedByPosition && _.map(eventsGroupedByPosition, itemGroup => { const firstItem = itemGroup[0]; const position = getPosition(firstItem, limit); let content: JSX.Element | null | string = null; const isPoint = firstItem.start === firstItem.end; if (isPoint) { // if this track has a getLineChartValue // configured for it, we use that function to obtain // a y value so that the point's can be represented and connected // as a line chart, with corresponding axis const y = getPointY( itemGroup, trackData, height, trackValueRange ); if (y !== null) { content = renderPoint( itemGroup, y, getTrackEventCustomColorGetterFromConfiguration( trackData ) ); linePoints.push({ x: position ? position.pixelLeft : 0, y, }); } } else if (position && position.pixelWidth) { content = renderRange( position.pixelWidth, itemGroup, getTrackEventCustomColorGetterFromConfiguration( trackData ) ); } return ( ); }); return ( {trackData.trackType === TimelineTrackType.LINE_CHART && renderTickGridLines(trackData, width)} {trackData.trackType === TimelineTrackType.LINE_CHART && renderLineChartConnectingLines(linePoints)} {points} ); } ); const TimelineItemWithTooltip: React.FunctionComponent<{ x: number | undefined; store: TimelineStore; track: TimelineTrackSpecification; events: TimelineEvent[]; content: any; }> = observer(function({ x, store, track, events, content }) { const [tooltipUid, setTooltipUid] = useState(null); const transforms = []; if (x) { transforms.push(`translate(${x} 0)`); } function syncTooltipUid() { if (tooltipUid && !store.doesTooltipExist(tooltipUid)) { setTooltipUid(null); return null; } return tooltipUid; } const hoverStyle = store.doesTooltipExist(tooltipUid || '') ? { opacity: 0.5, } : {}; return ( { let uid = syncTooltipUid(); if (!uid) { uid = store.addTooltip({ track, events, }); setTooltipUid(uid); store.setHoveredTooltipUid(uid); store.setMousePosition({ x: e.pageX, y: e.pageY, }); } }} onMouseLeave={e => { // we use a timeout here to allow user to // mouse into the tooltip (in order to copy or click a link) // the tooltip onEnter handler causes the tooltip to // become "pinned" meaning it is stuck open // that way when this timeout finally executes // the if statement will be false and the tooltip // will not be removed setTimeout(() => { const uid = syncTooltipUid(); if (uid && !store.isTooltipPinned(uid)) { store.removeTooltip(uid); setTooltipUid(null); } }, 100); }} onClick={() => { const uid = syncTooltipUid(); if (uid) { store.togglePinTooltip(uid); } }} > {content} ); }); export const OurPopup: React.FunctionComponent = observer(function( ...props ) { const store = useLocalObservable(() => ({ showModal: false, })); const showModal = useCallback(() => { const $modal = $(`
, `) .appendTo('body') .on('keydown', function(event) { if (event.key == 'Escape') { $modal.remove(); } }) .on('click', function(e) { if (/Open in New Window/.test(e.target.innerText)) { getBrowserWindow().open(props[0].href); } $modal.remove(); }); setTimeout(() => { $modal.find('iframe').attr('src', props[0].href); }, 500); }, []); return ( <> {props[0].children} ); }); export const EventTooltipContent: React.FunctionComponent<{ event: TimelineEvent; }> = function({ event }) { const attributes = event.event.attributes.filter(attr => { return ( attr.key !== COLOR_ATTRIBUTE_KEY && attr.key !== SHAPE_ATTRIBUTE_KEY ); }); return (
{_.map( attributes.sort((a: any, b: any) => a.key > b.key ? 1 : -1 ), (att: any) => { return ( ); } )} {event.event.endNumberOfDaysSinceDiagnosis && ( )}
{att.key.replace(/_/g, ' ')} ( ), }} > {att.value}
{`${ event.event.endNumberOfDaysSinceDiagnosis ? 'START DATE' : 'DATE' }`} {formatDate( event.event.startNumberOfDaysSinceDiagnosis )}
END DATE {formatDate( event.event.endNumberOfDaysSinceDiagnosis )}
); };