Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions static/app/components/charts/baseChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,11 @@ const getTooltipStyles = (p: {theme: Theme}) => css`
justify-content: flex-start;
align-items: baseline;
}
.tooltip-label-centered {
display: flex;
justify-content: center;
align-items: center;
}
.tooltip-code-no-margin {
padding-left: 0;
margin-left: 0;
Expand Down
15 changes: 0 additions & 15 deletions static/app/components/charts/components/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,21 +243,6 @@ export function getFormatter({
serie
);

if (serie.seriesType === 'heatmap') {
Comment thread
nikkikapadia marked this conversation as resolved.
const zAxisCountValue = (getSeriesValue(serie, 2) ?? 0).toString();
const yAxisValue = valueFormatter(
getSeriesValue(serie, 1),
serie.seriesName,
serie
);

acc.series.push(
`<div><span class="tooltip-label"><strong>${yAxisValue}</strong></span> ${zAxisCountValue}</div>`
);

return acc;
}

const value = valueFormatter(getSeriesValue(serie, 1), serie.seriesName, serie);

const marker = markerFormatter(serie.marker ?? '', serie.seriesName);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
import 'echarts/lib/chart/heatmap';

import {useRef} from 'react';
import {Fragment, useRef, type ReactNode} from 'react';
import {useTheme} from '@emotion/react';
import type {
TooltipFormatterCallback,
TopLevelFormatterParams,
} from 'echarts/types/dist/shared';

import {Flex} from '@sentry/scraps/layout';
import {useRenderToString} from '@sentry/scraps/renderToString';

import {BaseChart} from 'sentry/components/charts/baseChart';
import {getFormatter} from 'sentry/components/charts/components/tooltip';
import {isChartHovered, truncationFormatter} from 'sentry/components/charts/utils';
import {defaultFormatAxisLabel} from 'sentry/components/charts/components/tooltip';
import {isChartHovered} from 'sentry/components/charts/utils';
import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters';
import {t} from 'sentry/locale';
import type {ReactEchartsRef} from 'sentry/types/echarts';
import {defined} from 'sentry/utils';
import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
import {ECHARTS_MISSING_DATA_VALUE} from 'sentry/utils/timeSeries/timeSeriesItemToEChartsDataPoint';
import {useOrganization} from 'sentry/utils/useOrganization';
import {NO_PLOTTABLE_VALUES} from 'sentry/views/dashboards/widgets/common/settings';
import {plottablesCanBeVisualized} from 'sentry/views/dashboards/widgets/plottablesCanBeVisualized';
import {formatTooltipValue} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatTooltipValue';
import {formatXAxisTimestamp} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp';
import {formatYAxisValue} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatYAxisValue';
import {FALLBACK_TYPE} from 'sentry/views/dashboards/widgets/timeSeriesWidget/settings';
import {getExploreUrl, type GetExploreUrlArgs} from 'sentry/views/explore/utils';

import {HeatMap} from './plottables/heatMap';
import type {HeatMapPlottable} from './plottables/heatMapPlottable';
Expand All @@ -34,11 +41,17 @@ interface HeatMapWidgetVisualizationProps {
* Experimental! Specify the Z-axis scale type. Logarithmic scales can be much more useful for values with a high range.
*/
scale?: 'linear' | 'log';
/**
* getExploreUrl props that will be used to generate an explore link for the tooltip. Omitting this will not generate an explore link.
*/
tooltipExploreUrlArgs?: Omit<GetExploreUrlArgs, 'organization'>;
}

export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProps) {
const {plottables} = props;
const theme = useTheme();
const organization = useOrganization();
const renderToString = useRenderToString();

const pageFilters = usePageFilters();
const {start, end, period, utc} = pageFilters.selection.datetime;
Expand Down Expand Up @@ -68,43 +81,155 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp
const Zmax =
scale === 'log' ? Math.log1p(heatMapPlottable.Zend) : heatMapPlottable.Zend;

/** Extract the numeric value from ECharts tooltip param.value. */
function extractValue(data: unknown): number | null {
// param.value can be either:
// 1. The numeric value directly (for heatmap charts with axis trigger)
// 2. An object {name, value} (depends on series config)
if (typeof data === 'number') {
return data;
}

const value = (data as {value?: unknown} | null | undefined)?.value;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Any way we can get rid of this type cast 👀

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unfortunately not, the original typing for this value is from e-charts and this type in particular is not exported 😢

return typeof value === 'number' ? value : null;
}

// Create tooltip formatter
const formatTooltip: TooltipFormatterCallback<TopLevelFormatterParams> = (
params,
asyncTicket
) => {
const formatTooltip: TooltipFormatterCallback<TopLevelFormatterParams> = params => {
// Only show the tooltip of the current chart. Otherwise, all tooltips
// in the chart group appear.
if (!isChartHovered(chartRef?.current)) {
return '';
}

return getFormatter({
isGroupedByDate: true,
showTimeInTooltip: true,
nameFormatter: function (seriesName, _nameFormatterParams) {
return truncationFormatter(seriesName, true, false);
},
valueFormatter: function (value, _field, _valueFormatterParams) {
const bucketSize = heatMapPlottable.heatMapSeries.meta.yAxis.bucketSize;
const fieldType = heatMapPlottable?.yAxisValueType ?? FALLBACK_TYPE;

const yAxisMinValueFormatted = formatTooltipValue(
value,
fieldType,
heatMapPlottable.yAxisValueUnit ?? undefined
);
const yAxisMaxValueFormatted = formatTooltipValue(
value + bucketSize,
fieldType,
heatMapPlottable.yAxisValueUnit ?? undefined
);

return `${yAxisMinValueFormatted} – ${yAxisMaxValueFormatted}`;
},
truncate: false,
utc: utc ?? false,
})(params, asyncTicket);
const seriesParams = Array.isArray(params) ? params : [params];

// Filter null values from tooltip
const filteredParams = seriesParams.filter(param => {
// @ts-expect-error ECharts types param.value as unknown, but we know it's [xAxis, yAxis, zAxis] from our HeatMap plottable
const value = extractValue(param.value[2]);
return value !== null;
});
Comment thread
cursor[bot] marked this conversation as resolved.

let formattedXValue = ECHARTS_MISSING_DATA_VALUE;

const xAxisBucketSize = heatMapPlottable.heatMapSeries.meta.xAxis.bucketSize;
const yAxisBucketSize = heatMapPlottable.heatMapSeries.meta.yAxis.bucketSize;
const yAxisUnit = heatMapPlottable?.yAxisValueUnit;
const yAxisValueType = heatMapPlottable?.yAxisValueType ?? FALLBACK_TYPE;

return renderToString(
<Fragment>
<div className="tooltip-series">
{filteredParams.map(param => {
let rawXValue: number | undefined;
let rawYValue: number | undefined;

let formattedYValue = ECHARTS_MISSING_DATA_VALUE;
let formattedZValue = ECHARTS_MISSING_DATA_VALUE;
if (Array.isArray(param.value) && param.value.length === 3) {
const [xValue, yValue, zValue] = param.value;

if (defined(xValue) && typeof xValue === 'number') {
rawXValue = xValue;
// bucket size seems to be in seconds but we need to convert to milliseconds
formattedXValue = defaultFormatAxisLabel(
xValue,
true,
utc ?? false,
true,
false,
xAxisBucketSize * 1000
).toString();
}

if (defined(yValue) && typeof yValue === 'number') {
rawYValue = yValue;
const yAxisMinValueFormatted = formatTooltipValue(
yValue,
yAxisValueType,
yAxisUnit ?? undefined
);

if (yAxisBucketSize === 0) {
formattedYValue = yAxisMinValueFormatted;
} else {
const yAxisMaxValueFormatted = formatTooltipValue(
yValue + yAxisBucketSize,
yAxisValueType,
yAxisUnit ?? undefined
);

formattedYValue = `${yAxisMinValueFormatted} – ${yAxisMaxValueFormatted}`;
}
}

if (defined(zValue) && typeof zValue === 'number') {
formattedZValue = formatAbbreviatedNumber(zValue, 4, false);
}
Comment thread
nikkikapadia marked this conversation as resolved.
}

let exploreLink: ReactNode;

if (defined(rawXValue) && defined(rawYValue) && props.tooltipExploreUrlArgs) {
const xAxisMaxValue = rawXValue + xAxisBucketSize * 1000;
const yAxisMaxValue = rawYValue + yAxisBucketSize;

const exploreUrlProps: GetExploreUrlArgs = {
organization,
...props.tooltipExploreUrlArgs,
selection: {
...pageFilters.selection,
datetime: {
...pageFilters.selection.datetime,
start: new Date(rawXValue),
end: new Date(xAxisMaxValue),
period: null,
},
},
// TODO(nikki): we're only handling metrics for now but if we're looking to support other explore
// surfaces then we'll need to add more logic here
crossEvents: props.tooltipExploreUrlArgs?.crossEvents?.map(crossEvent => {
if (crossEvent.type === 'metrics') {
return {
...crossEvent,
query:
yAxisBucketSize === 0
? `value:<=${rawYValue}`
: `value:>=${rawYValue} value:<${yAxisMaxValue}`,
};
}
return crossEvent;
}),
Comment thread
cursor[bot] marked this conversation as resolved.
};

const tracesLink = getExploreUrl(exploreUrlProps);
exploreLink = <a href={tracesLink}>{t('View related traces')}</a>;
Comment thread
cursor[bot] marked this conversation as resolved.
}

return (
<Fragment key={param.seriesIndex}>
<div>
<span className="tooltip-label">
<strong>{formattedYValue}</strong>
</span>{' '}
{formattedZValue}
</div>
{exploreLink && (
<div>
<span className="tooltip-label tooltip-label-centered">
{exploreLink}
</span>
</div>
)}
</Fragment>
);
})}
</div>
<div className="tooltip-footer tooltip-footer-centered">{formattedXValue}</div>
<div className="tooltip-arrow" />
</Fragment>
);
};

return (
Expand All @@ -118,6 +243,8 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp
ref={chartRef}
tooltip={{
show: true,
enterable: true,
extraCssText: `box-shadow: 0 0 0 1px ${theme.tokens.border.transparent.neutral.muted}, ${theme.shadow.high}; z-index: ${theme.zIndex.tooltip} !important; pointer-events: auto !important;`,
axisPointer: {
show: false,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class HeatMap implements HeatMapPlottable {

toSeries(plottingOptions: HeatMapPlottingOptions): SeriesOption[] {
const {heatMapSeries} = this;
const {scale = 'linear'} = plottingOptions;
const {scale = 'linear', theme} = plottingOptions;

return [
{
Expand All @@ -64,7 +64,10 @@ export class HeatMap implements HeatMapPlottable {
];
}),
emphasis: {
disabled: true,
itemStyle: {
borderColor: theme.tokens.border.onVibrant.dark,
borderWidth: parseInt(theme.border.xl.replace('px', ''), 10),
},
},
},
];
Expand Down
18 changes: 17 additions & 1 deletion static/app/views/explore/metrics/metricsHeatMap.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {useMemo} from 'react';
import type {UseQueryResult} from '@tanstack/react-query';

import {t} from 'sentry/locale';
Expand All @@ -12,9 +13,10 @@ import {
useMetricName,
useMetricVisualize,
useMetricVisualizes,
useTraceMetric,
} from 'sentry/views/explore/metrics/metricsQueryParams';
import {STACKED_GRAPH_HEIGHT} from 'sentry/views/explore/metrics/settings';
import {prettifyAggregation} from 'sentry/views/explore/utils';
import {prettifyAggregation, type GetExploreUrlArgs} from 'sentry/views/explore/utils';

interface MetricsHeatMapProps {
actions: React.ReactNode;
Expand All @@ -27,6 +29,7 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr
const visualizes = useMetricVisualizes();
const metricLabel = useMetricLabel();
const metricName = useMetricName();
const metric = useTraceMetric();

const {data: heatMapSeries, isPending, error} = heatmapResult;

Expand All @@ -36,6 +39,18 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr
? metricName
: (title ?? metricLabel ?? prettifyAggregation(aggregate) ?? aggregate);

const tooltipExploreUrlArgs: Omit<GetExploreUrlArgs, 'organization'> = useMemo(() => {
return {
crossEvents: [
{
type: 'metrics',
query: '',
metric,
},
],
};
}, [metric]);

return (
<WidgetWrapper>
<Widget
Expand All @@ -52,6 +67,7 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr
<HeatMapWidgetVisualization
plottables={[new HeatMap(heatMapSeries)]}
scale="log"
tooltipExploreUrlArgs={tooltipExploreUrlArgs}
/>
)
}
Expand Down
Loading