-
-
Notifications
You must be signed in to change notification settings - Fork 4.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(ui): Add grouped releases for charts [WIP]
This is a draft PR to ensure we can manipulate echarts the way we want to. What we are trying to do instead of drawing a single line per release is to group releases up into time buckets and show them as a bubble below the main chart. This demonstrates the ability to do this as well as: * shading the relevant area on chart when hovering over bubble * highlighting relevant bubble when moving mouse around the chart * click handler for the bubble * tooltips function as expected between release bubbles and main chart
- Loading branch information
Showing
3 changed files
with
375 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
315 changes: 315 additions & 0 deletions
315
static/app/views/dashboards/widgets/timeSeriesWidget/releaseBubbles.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,315 @@ | ||
import type {Theme} from '@emotion/react'; | ||
import type { | ||
CustomSeriesOption, | ||
CustomSeriesRenderItem, | ||
CustomSeriesRenderItemAPI, | ||
CustomSeriesRenderItemParams, | ||
CustomSeriesRenderItemReturn, | ||
ElementEvent, | ||
} from 'echarts'; | ||
import type {EChartsInstance} from 'echarts-for-react'; | ||
|
||
import {t} from 'sentry/locale'; | ||
import type { | ||
EChartMouseOutHandler, | ||
EChartMouseOverHandler, | ||
Series, | ||
} from 'sentry/types/echarts'; | ||
import {defined} from 'sentry/utils'; | ||
|
||
import type {Release, TimeSeries} from '../common/types'; | ||
|
||
type Bucket = [start: number, placeholder: number, end: number, releases: number]; | ||
|
||
export const RELEASE_BUBBLE_SIZE = 14; // TODO: find a proper size | ||
|
||
/** | ||
* Attaches an event listener to eCharts that will "highlight" a release bubble | ||
* as the mouse moves around the main chart. | ||
* | ||
* Note: you cannot listen to "mousemove" events on echarts as they only | ||
* contain events when the mouse interacts with a data item. This needs to | ||
* listen to zrender (i.e. the `getZr()`) events. | ||
*/ | ||
export function createReleaseBubbleHighlighter(echartsInstance: EChartsInstance) { | ||
const highlightedBuckets = new Set(); | ||
function handleMouseMove(params: ElementEvent) { | ||
// Tracks movement across the chart and highlights the corresponding release bubble | ||
const pointInPixel = [params.offsetX, params.offsetY]; | ||
const pointInGrid = echartsInstance.convertFromPixel('grid', pointInPixel); | ||
const series = echartsInstance.getOption().series; | ||
const seriesIndex = series.findIndex((s: Series) => s.id === 'release-bubble'); | ||
|
||
// No release bubble series found (shouldn't happen) | ||
if (seriesIndex === -1) { | ||
return; | ||
} | ||
const bubbleSeries = series[seriesIndex]; | ||
const buckets = bubbleSeries?.data; | ||
|
||
if (!buckets) { | ||
return; | ||
} | ||
|
||
const bucketIndex = buckets.findIndex(([bucketStart, _, bucketEnd]: Bucket) => { | ||
const ts = pointInGrid[0] ?? -1; | ||
return ts >= bucketStart && ts < bucketEnd; | ||
}); | ||
|
||
// Already highlighted, no need to do anything | ||
if (highlightedBuckets.has(bucketIndex)) { | ||
return; | ||
} | ||
|
||
// If next bucket is not already highlighted, clear all existing | ||
// highlights. | ||
if (!highlightedBuckets.has(bucketIndex)) { | ||
highlightedBuckets.forEach(dataIndex => { | ||
echartsInstance.dispatchAction({ | ||
type: 'downplay', | ||
seriesIndex, | ||
dataIndex, | ||
}); | ||
}); | ||
highlightedBuckets.clear(); | ||
} | ||
|
||
if (bucketIndex > -1) { | ||
highlightedBuckets.add(bucketIndex); | ||
echartsInstance.dispatchAction({ | ||
type: 'highlight', | ||
seriesIndex, | ||
dataIndex: bucketIndex, | ||
}); | ||
} | ||
} | ||
|
||
echartsInstance.getZr().on('mousemove', handleMouseMove); | ||
} | ||
|
||
/** | ||
* MouseListeners for echarts. This includes drawing a highlighted area on the | ||
* main chart when a release bubble is hovered over. | ||
*/ | ||
export function createReleaseBubbleMouseListeners(color: string) { | ||
return { | ||
onMouseOut: ( | ||
params: Parameters<EChartMouseOutHandler>[0], | ||
instance: EChartsInstance | ||
) => { | ||
if (params.seriesId === 'release-bubble') { | ||
instance.setOption({ | ||
series: [{id: 'release-mark-area', markArea: {data: []}}], | ||
}); | ||
} | ||
}, | ||
onMouseOver: ( | ||
params: Parameters<EChartMouseOverHandler>[0], | ||
instance: EChartsInstance | ||
) => { | ||
if (params.seriesId === 'release-bubble') { | ||
instance.setOption({ | ||
series: [ | ||
{ | ||
id: 'release-mark-area', | ||
type: 'custom', | ||
renderItem: () => {}, | ||
markArea: { | ||
itemStyle: {color, opacity: 0.1}, | ||
data: [ | ||
[ | ||
{ | ||
xAxis: params.data[0], | ||
}, | ||
{ | ||
xAxis: params.data[2], | ||
}, | ||
], | ||
], | ||
}, | ||
}, | ||
], | ||
}); | ||
} | ||
}, | ||
}; | ||
} | ||
|
||
export function createReleaseBuckets( | ||
timeSeries: Array<Readonly<TimeSeries>>, | ||
releases: Release[] | ||
): Bucket[] { | ||
if (!timeSeries.length || !timeSeries[0]) { | ||
return []; | ||
} | ||
|
||
const buckets: Bucket[] = []; | ||
|
||
// we need to create release buckets using the time intervals specified in `timeseries` | ||
// assume that all timeseries will be equal in length, if not we'll need to search all timeseries | ||
|
||
let releaseIterator = releases.length - 1; | ||
const {data} = timeSeries[0]; | ||
const MAGIC_INTERVAL = Math.floor(data.length / 10); // @TODO figure out how to calculate the magic interval | ||
|
||
for (let i = 0; i < data.length; i += MAGIC_INTERVAL) { | ||
if (i >= data.length) { | ||
break; | ||
} | ||
|
||
const bucketStartSeriesItem = data[i]; | ||
const bucketEndSeriesItem = data[Math.min(i + MAGIC_INTERVAL, data.length - 1)]; | ||
|
||
if (!bucketStartSeriesItem || !bucketEndSeriesItem) { | ||
break; | ||
} | ||
|
||
const bucketStart = new Date(bucketStartSeriesItem.timestamp); | ||
const bucketEnd = new Date(bucketEndSeriesItem.timestamp); | ||
|
||
const releasesInBucket = []; | ||
|
||
// For my test data, props.releases is in descending order, whereas `timeSeries` is ascending | ||
// We probably should ensure it's sorted and order it ascending | ||
for (let j = releaseIterator; j >= 0; j--) { | ||
const release = releases[j]; | ||
if (!release) { | ||
break; | ||
} | ||
const releaseTs = new Date(release.timestamp); | ||
|
||
// If release timestamp is within bounds of the bucketStart bucket, add the release to list | ||
if (releaseTs >= bucketStart && releaseTs < bucketEnd) { | ||
releasesInBucket.push(release); | ||
} | ||
// Since releases are sorted, once a release timestamp is more recent than the bucket timestamp, | ||
// we want to break this innerloop and move on to the bucketEnd bucket. | ||
// | ||
// Also we can preserve releaseIterator as it is sorted so we can skip | ||
// releases that have already been processed | ||
if (releaseTs >= bucketEnd) { | ||
// break this innner for loop and move to bucketEnd bucket | ||
releaseIterator = j; | ||
break; | ||
} | ||
} | ||
|
||
buckets.push([ | ||
bucketStart.getTime(), | ||
0, | ||
bucketEnd.getTime(), | ||
releasesInBucket.length, | ||
]); | ||
} | ||
|
||
return buckets; | ||
} | ||
|
||
interface ReleaseBubbleSeriesProps { | ||
releases: Release[]; | ||
theme: Theme; | ||
timeSeries: Array<Readonly<TimeSeries>>; | ||
} | ||
|
||
export function ReleaseBubbleSeries({ | ||
releases, | ||
timeSeries, | ||
theme, | ||
}: ReleaseBubbleSeriesProps): CustomSeriesOption | null { | ||
const buckets = createReleaseBuckets(timeSeries, releases); | ||
if (!buckets.length) { | ||
return null; | ||
} | ||
|
||
/** | ||
* Renders release bubbles underneath the main chart | ||
*/ | ||
const renderReleaseBubble: CustomSeriesRenderItem = ( | ||
_params: CustomSeriesRenderItemParams, | ||
api: CustomSeriesRenderItemAPI | ||
) => { | ||
// api.value(index) returns the value at Bucket[index] | ||
// Unfortunately, it seems only integer values are allowed, it'll otherwise | ||
// return NaN (this could be due to the chart being a timeseries). | ||
// | ||
// Because we are drawing rectangles with a known height, we don't care | ||
// about the y-value (which I think is the 2nd tuple value passed to | ||
// `api.coord()`). | ||
const [bubbleStartX, bubbleStartY] = api.coord([api.value(0), 0]); | ||
const [bubbleEndX, bubbleEndY] = api.coord([api.value(2), 0]); | ||
|
||
if ( | ||
!defined(bubbleStartX) || | ||
!defined(bubbleStartY) || | ||
!defined(bubbleEndX) || | ||
!defined(bubbleEndY) | ||
) { | ||
return null; | ||
} | ||
|
||
const numberReleases = api.value(3); | ||
const width = bubbleEndX - bubbleStartX; | ||
const shape = { | ||
x: bubbleStartX + 1, | ||
y: bubbleStartY + 4, | ||
width: width - 2, | ||
height: RELEASE_BUBBLE_SIZE - 8, | ||
r: 4, | ||
}; | ||
|
||
return { | ||
type: 'rect', | ||
transition: ['shape'], | ||
shape, | ||
style: { | ||
fill: theme.blue400, | ||
// @TODO figure out correct opacity calculations | ||
// api.valu | ||
opacity: Number(numberReleases) * 0.1, | ||
}, | ||
emphasis: { | ||
style: { | ||
stroke: theme.blue300, | ||
}, | ||
}, | ||
} as CustomSeriesRenderItemReturn; | ||
}; | ||
|
||
return { | ||
id: 'release-bubble', | ||
type: 'custom', | ||
name: t('Releases'), | ||
renderItem: renderReleaseBubble, | ||
data: buckets, | ||
// triggerLineEvent: true, | ||
tooltip: { | ||
trigger: 'item', | ||
position: 'bottom', | ||
backgroundColor: `${theme.backgroundElevated}`, | ||
borderWidth: 0, | ||
extraCssText: `box-shadow: 0 0 0 1px ${theme.translucentBorder}, ${theme.dropShadowHeavy}`, | ||
transitionDuration: 0, | ||
padding: 0, | ||
formatter: params => { | ||
const bucket = params.data as Bucket; | ||
const numberReleases = Number(bucket[3]); | ||
return ` | ||
<div class="tooltip-series"> | ||
<div> | ||
${numberReleases} Releases | ||
</div> | ||
</div> | ||
${ | ||
numberReleases > 0 | ||
? `<div class="tooltip-footer"> | ||
Tap to view | ||
</div>` | ||
: '' | ||
} | ||
<div class="tooltip-arrow arrow-top"></div> | ||
`; | ||
}, | ||
}, | ||
}; | ||
} |
Oops, something went wrong.