Skip to content

Commit

Permalink
feat(ui): Add grouped releases for charts [WIP]
Browse files Browse the repository at this point in the history
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
billyvg committed Feb 27, 2025
1 parent 17320bc commit 1af0aea
Show file tree
Hide file tree
Showing 3 changed files with 375 additions and 21 deletions.
13 changes: 13 additions & 0 deletions static/app/components/charts/baseChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,19 @@ const getTooltipStyles = (p: {theme: Theme}) => css`
}
.tooltip-arrow {
&.arrow-top {
bottom: 100%;
top: auto;
border-bottom: 8px solid ${p.theme.backgroundElevated};
border-top: none;
&:before {
border-top: none;
border-bottom: 8px solid ${p.theme.translucentBorder};
bottom: -7px;
top: auto;
}
}
top: 100%;
left: 50%;
position: absolute;
Expand Down
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>
`;
},
},
};
}
Loading

0 comments on commit 1af0aea

Please sign in to comment.