From 166dd4e1c60730964870a4104af72dd30cddd05f Mon Sep 17 00:00:00 2001 From: Abdullah Khan Date: Sat, 29 Nov 2025 19:39:16 -0500 Subject: [PATCH 01/12] feat(explore-attr-breakdowns): Adding chart tooltip actions --- .../attributeDistributionChart.tsx | 14 ++-- .../hooks/useAttributeBreakdownsTooltip.tsx | 70 +++++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx diff --git a/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx b/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx index 4bc58343d7f19a..24275c9a7b2954 100644 --- a/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx +++ b/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx @@ -10,6 +10,7 @@ import {Flex} from 'sentry/components/core/layout'; import {tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {ReactEchartsRef} from 'sentry/types/echarts'; +import {useAttributeBreakdownsTooltip} from 'sentry/views/explore/hooks/useAttributeBreakdownsTooltip'; import type {AttributeDistribution} from './attributeDistributionContent'; @@ -101,10 +102,14 @@ export function Chart({ `
${truncatedValue}
`, '', ``, - '
', ].join(''); }, []); + const tooltipConfig = useAttributeBreakdownsTooltip({ + chartRef, + formatter: toolTipFormatter, + }); + const chartXAxisLabelFormatter = useCallback( (value: string): string => { const labelsCount = seriesData.length > 0 ? seriesData.length : 1; @@ -166,12 +171,7 @@ export function Chart({ ref={chartRef} autoHeightResize isGroupedByDate={false} - tooltip={{ - appendToBody: true, - trigger: 'axis', - renderMode: 'html', - formatter: toolTipFormatter, - }} + tooltip={tooltipConfig} grid={{ left: 2, right: 8, diff --git a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx new file mode 100644 index 00000000000000..3c38993f5cdc3b --- /dev/null +++ b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx @@ -0,0 +1,70 @@ +import {useEffect, useRef} from 'react'; +import type {ECharts, TooltipComponentFormatterCallbackParams} from 'echarts'; + +import type {TooltipOption} from 'sentry/components/charts/baseChart'; +import type {ReactEchartsRef} from 'sentry/types/echarts'; + +type Params = { + chartRef: React.RefObject; + formatter: (params: TooltipComponentFormatterCallbackParams) => string; +}; + +export function useAttributeBreakdownsTooltip({ + chartRef, + formatter, +}: Params): TooltipOption { + const frozenPositionRef = useRef<[number, number] | null>(null); + const tooltipParamsRef = useRef(null); + + useEffect(() => { + const chartInstance: ECharts | undefined = chartRef.current?.getEchartsInstance(); + if (!chartInstance) return; + + const dom = chartInstance.getDom(); + + const handleClickAnywhere = (event: MouseEvent) => { + event.preventDefault(); + + const pixelPoint: [number, number] = [event.offsetX, event.offsetY]; + + if (frozenPositionRef.current) { + frozenPositionRef.current = null; + tooltipParamsRef.current = null; + } else { + frozenPositionRef.current = pixelPoint; + } + }; + + dom.addEventListener('click', handleClickAnywhere); + + // eslint-disable-next-line consistent-return + return () => { + dom.removeEventListener('click', handleClickAnywhere); + }; + }, [chartRef, formatter, tooltipParamsRef, frozenPositionRef]); + + // Override tooltip behavior via formatter and position + const tooltipConfig = { + trigger: 'axis' as const, + appendToBody: true as const, + renderMode: 'html' as const, + formatter: (params: TooltipComponentFormatterCallbackParams) => { + if (!frozenPositionRef.current || !tooltipParamsRef.current) { + tooltipParamsRef.current = params; + return formatter(params); + } + return formatter(tooltipParamsRef.current); + }, + position: (point: number[]) => { + if (frozenPositionRef.current) { + const [x, y] = frozenPositionRef.current; + return [x + 20, y]; // frozen tooltip: move 20px right + } + + const [x = 0, y = 0] = point; + return [x + 20, y]; + }, + }; + + return tooltipConfig; +} From 1f07558051cb7e1c08446d5e84c0b2d635050d6f Mon Sep 17 00:00:00 2001 From: Abdullah Khan Date: Sat, 29 Nov 2025 22:31:47 -0500 Subject: [PATCH 02/12] feat(explore-attr-breakdowns): Implementing for default case --- .../attributeDistributionChart.tsx | 105 +++++++++++++- .../hooks/useAttributeBreakdownsTooltip.tsx | 132 +++++++++++++++--- 2 files changed, 209 insertions(+), 28 deletions(-) diff --git a/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx b/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx index 24275c9a7b2954..404591873cd54a 100644 --- a/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx +++ b/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx @@ -10,7 +10,10 @@ import {Flex} from 'sentry/components/core/layout'; import {tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {ReactEchartsRef} from 'sentry/types/echarts'; -import {useAttributeBreakdownsTooltip} from 'sentry/views/explore/hooks/useAttributeBreakdownsTooltip'; +import { + Actions, + useAttributeBreakdownsTooltip, +} from 'sentry/views/explore/hooks/useAttributeBreakdownsTooltip'; import type {AttributeDistribution} from './attributeDistributionContent'; @@ -97,17 +100,105 @@ export function Chart({ ? `${value.slice(0, TOOLTIP_MAX_VALUE_LENGTH)}...` : value : '\u2014'; - return [ - '
', - `
${truncatedValue}
`, - '
', - ``, - ].join(''); + return ` +
+
+ ${truncatedValue} + ${pct} +
+
+ `.trim(); }, []); + const tooltipActionsHtmlRenderer = useCallback( + (value: string) => { + if (!value) return ''; + + const actionBackground = theme.gray200; + return [ + '', + ' ', + ' Group by attribute', + ' ', + ' ', + ' Add value to filter', + ' ', + ' ', + ' Exclude value from filter', + ' ', + ' ', + ' Copy value to clipboard', + ' ', + '', + ] + .join('\n') + .trim(); + }, + [theme.gray200, attributeDistribution.name] + ); + const tooltipConfig = useAttributeBreakdownsTooltip({ chartRef, formatter: toolTipFormatter, + chartWidth, + actionsHtmlRenderer: tooltipActionsHtmlRenderer, + attributeName: attributeDistribution.name, }); const chartXAxisLabelFormatter = useCallback( diff --git a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx index 3c38993f5cdc3b..9f73491ce26a5c 100644 --- a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx +++ b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx @@ -1,37 +1,65 @@ -import {useEffect, useRef} from 'react'; +import {useEffect, useRef, useState} from 'react'; import type {ECharts, TooltipComponentFormatterCallbackParams} from 'echarts'; import type {TooltipOption} from 'sentry/components/charts/baseChart'; import type {ReactEchartsRef} from 'sentry/types/echarts'; +import useCopyToClipboard from 'sentry/utils/useCopyToClipboard'; +import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; +import { + useAddSearchFilter, + useSetQueryParamsGroupBys, +} from 'sentry/views/explore/queryParams/context'; type Params = { + attributeName: string; chartRef: React.RefObject; + chartWidth: number; formatter: (params: TooltipComponentFormatterCallbackParams) => string; + actionsHtmlRenderer?: (value: string) => string; }; +export enum Actions { + GROUP_BY = 'group_by_attribute', + ADD_TO_FILTER = 'add_value_to_filter', + EXCLUDE_FROM_FILTER = 'exclude_value_from_filter', + COPY_TO_CLIPBOARD = 'copy_value_to_clipboard', +} + export function useAttributeBreakdownsTooltip({ chartRef, formatter, + chartWidth, + actionsHtmlRenderer, }: Params): TooltipOption { - const frozenPositionRef = useRef<[number, number] | null>(null); + const [frozenPosition, setFrozenPosition] = useState<[number, number] | null>(null); const tooltipParamsRef = useRef(null); + const addSearchFilter = useAddSearchFilter(); + const copyToClipboard = useCopyToClipboard(); + const setGroupBys = useSetQueryParamsGroupBys(); useEffect(() => { const chartInstance: ECharts | undefined = chartRef.current?.getEchartsInstance(); if (!chartInstance) return; + if (frozenPosition) { + chartInstance.dispatchAction({ + type: 'showTip', + x: frozenPosition[0], + y: frozenPosition[1], + }); + } + const dom = chartInstance.getDom(); const handleClickAnywhere = (event: MouseEvent) => { event.preventDefault(); - const pixelPoint: [number, number] = [event.offsetX, event.offsetY]; - if (frozenPositionRef.current) { - frozenPositionRef.current = null; + if (frozenPosition) { + setFrozenPosition(null); tooltipParamsRef.current = null; } else { - frozenPositionRef.current = pixelPoint; + setFrozenPosition(pixelPoint); } }; @@ -41,28 +69,90 @@ export function useAttributeBreakdownsTooltip({ return () => { dom.removeEventListener('click', handleClickAnywhere); }; - }, [chartRef, formatter, tooltipParamsRef, frozenPositionRef]); + }, [chartRef, frozenPosition]); + + useEffect(() => { + if (!frozenPosition) return; + + const handleClickActions = (event: MouseEvent) => { + event.preventDefault(); + + const target = event.target as HTMLElement; + const action = target.getAttribute('data-tooltip-action'); + const key = target.getAttribute('data-tooltip-action-key'); + const value = target.getAttribute('data-tooltip-action-value'); + + if (action && value && key) { + switch (action) { + case Actions.GROUP_BY: + setGroupBys([key], Mode.AGGREGATE); + break; + case Actions.ADD_TO_FILTER: + addSearchFilter({ + key, + value, + }); + break; + case Actions.EXCLUDE_FROM_FILTER: + addSearchFilter({ + key, + value, + negated: true, + }); + break; + case Actions.COPY_TO_CLIPBOARD: + copyToClipboard.copy(value); + break; + default: + throw new Error(`Unknown attribute breakdowns tooltip action: ${action}`); + } + } + }; + + document.addEventListener('click', handleClickActions); + + // eslint-disable-next-line consistent-return + return () => { + document.removeEventListener('click', handleClickActions); + }; + }, [frozenPosition, copyToClipboard, addSearchFilter, setGroupBys]); - // Override tooltip behavior via formatter and position - const tooltipConfig = { - trigger: 'axis' as const, - appendToBody: true as const, - renderMode: 'html' as const, + const tooltipConfig: TooltipOption = { + trigger: 'axis', + appendToBody: true, + renderMode: 'html', + enterable: true, formatter: (params: TooltipComponentFormatterCallbackParams) => { - if (!frozenPositionRef.current || !tooltipParamsRef.current) { + if (!frozenPosition) { tooltipParamsRef.current = params; - return formatter(params); + return ( + formatter(params) + + '' + ); } - return formatter(tooltipParamsRef.current); + + const value = (Array.isArray(params) ? params[0]?.name : params.name) ?? ''; + return formatter(tooltipParamsRef.current!) + actionsHtmlRenderer?.(value); }, - position: (point: number[]) => { - if (frozenPositionRef.current) { - const [x, y] = frozenPositionRef.current; - return [x + 20, y]; // frozen tooltip: move 20px right + position( + point: [number, number], + _params: TooltipComponentFormatterCallbackParams, + el: any + ) { + const dom = el as HTMLDivElement; + const tooltipWidth = dom?.offsetWidth ?? 0; + const [rawX = 0, rawY = 0] = frozenPosition ?? point; + + const offsetX = 20; + let x = rawX + offsetX; + const y = rawY; + + // flip left if it overflows chart width + if (x + tooltipWidth > chartWidth) { + x = rawX - tooltipWidth - offsetX; } - const [x = 0, y = 0] = point; - return [x + 20, y]; + return [x, y]; }, }; From 1d41b01937f2e43be191c0b6a327070d3d0b8734 Mon Sep 17 00:00:00 2001 From: Abdullah Khan Date: Sun, 30 Nov 2025 15:03:15 -0500 Subject: [PATCH 03/12] feat(explore-attr-breakdowns): Adding tooltip actions for comparison charts --- .../attributeDistributionChart.tsx | 11 +- .../cohortComparisonChart.tsx | 148 +++++++++++++----- .../hooks/useAttributeBreakdownsTooltip.tsx | 4 +- 3 files changed, 119 insertions(+), 44 deletions(-) diff --git a/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx b/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx index 404591873cd54a..0a8328fdbeeb3a 100644 --- a/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx +++ b/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx @@ -142,14 +142,14 @@ export function Chart({ ' align-items: center;', ' flex-direction: column;', ' padding: 0;', - ' gap: 4px;', + ' gap: 0;', ' "', '>', ' ', @@ -159,7 +159,7 @@ export function Chart({ ` data-tooltip-action="${Actions.ADD_TO_FILTER}"`, ` data-tooltip-action-key="${attributeDistribution.name}"`, ` data-tooltip-action-value="${value}"`, - ' style="width: 100%; padding: 8px 20px; cursor: pointer; transition: background 0.2s;"', + ' style="width: 100%; padding: 8px 20px; cursor: pointer;"', ` onmouseover="this.style.background='${actionBackground}'"`, ' onmouseout="this.style.background=\'\'"', ' >', @@ -169,7 +169,7 @@ export function Chart({ ` data-tooltip-action="${Actions.EXCLUDE_FROM_FILTER}"`, ` data-tooltip-action-key="${attributeDistribution.name}"`, ` data-tooltip-action-value="${value}"`, - ' style="width: 100%; padding: 8px 20px; cursor: pointer; transition: background 0.2s;"', + ' style="width: 100%; padding: 8px 20px; cursor: pointer;"', ` onmouseover="this.style.background='${actionBackground}'"`, ' onmouseout="this.style.background=\'\'"', ' >', @@ -179,7 +179,7 @@ export function Chart({ ` data-tooltip-action="${Actions.COPY_TO_CLIPBOARD}"`, ` data-tooltip-action-key="${attributeDistribution.name}"`, ` data-tooltip-action-value="${value}"`, - ' style="width: 100%; padding: 8px 20px; cursor: pointer; transition: background 0.2s;"', + ' style="width: 100%; padding: 8px 20px; cursor: pointer;"', ` onmouseover="this.style.background='${actionBackground}'"`, ' onmouseout="this.style.background=\'\'"', ' >', @@ -198,7 +198,6 @@ export function Chart({ formatter: toolTipFormatter, chartWidth, actionsHtmlRenderer: tooltipActionsHtmlRenderer, - attributeName: attributeDistribution.name, }); const chartXAxisLabelFormatter = useCallback( diff --git a/static/app/views/explore/components/attributeBreakdowns/cohortComparisonChart.tsx b/static/app/views/explore/components/attributeBreakdowns/cohortComparisonChart.tsx index ab3e2ee7460c3c..dea7085fe8d0a5 100644 --- a/static/app/views/explore/components/attributeBreakdowns/cohortComparisonChart.tsx +++ b/static/app/views/explore/components/attributeBreakdowns/cohortComparisonChart.tsx @@ -2,7 +2,6 @@ import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {Theme} from '@emotion/react'; import styled from '@emotion/styled'; import type {TooltipComponentFormatterCallbackParams} from 'echarts'; -import type {CallbackDataParams} from 'echarts/types/dist/shared'; import {Tooltip} from '@sentry/scraps/tooltip/tooltip'; @@ -12,6 +11,10 @@ import {tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {ReactEchartsRef} from 'sentry/types/echarts'; import type {AttributeBreakdownsComparison} from 'sentry/views/explore/hooks/useAttributeBreakdownComparison'; +import { + Actions, + useAttributeBreakdownsTooltip, +} from 'sentry/views/explore/hooks/useAttributeBreakdownsTooltip'; const MAX_BAR_WIDTH = 20; const HIGH_CARDINALITY_THRESHOLD = 20; @@ -164,50 +167,129 @@ export function Chart({ [attribute.cohort1, attribute.cohort2, cohort1Total, cohort2Total] ); - const toolTipValueFormatter = useCallback( - (_value: number, _label?: string, seriesParams?: CallbackDataParams) => { - const percentage = Number(seriesParams?.data); - return percentageFormatter(percentage); - }, - [] - ); - - const toolTipFormatAxisLabel = useCallback( - ( - _value: number, - _isTimestamp: boolean, - _utc: boolean, - _showTimeInTooltip: boolean, - _addSecondsToTimeFormat: boolean, - _bucketSize: number | undefined, - seriesParamsOrParam: TooltipComponentFormatterCallbackParams - ) => { - if (!Array.isArray(seriesParamsOrParam)) { + const toolTipFormatter = useCallback( + (p: TooltipComponentFormatterCallbackParams) => { + if (!Array.isArray(p)) { return '\u2014'; } - const selectedParam = seriesParamsOrParam.find( - s => s.seriesName === SELECTED_SERIES_NAME - ); - const baselineParam = seriesParamsOrParam.find( - s => s.seriesName === BASELINE_SERIES_NAME - ); + const selectedParam = p.find(s => s.seriesName === SELECTED_SERIES_NAME); + const baselineParam = p.find(s => s.seriesName === BASELINE_SERIES_NAME); if (!selectedParam || !baselineParam) { throw new Error('selectedParam or baselineParam is not defined'); } - const name = selectedParam?.name ?? baselineParam?.name ?? ''; + const selectedValue = selectedParam.value; + const baselineValue = baselineParam.value; + const selectedPct = percentageFormatter(Number(selectedValue)); + const baselinePct = percentageFormatter(Number(baselineValue)); + + const name = selectedParam.name ?? baselineParam.name ?? ''; const truncatedName = name.length > TOOLTIP_MAX_VALUE_LENGTH ? `${name.slice(0, TOOLTIP_MAX_VALUE_LENGTH)}...` : name; - return `
${truncatedName}
`; + return ` +
+
+ ${truncatedName} + + + + selected + + ${selectedPct} + + + + + baseline + + ${baselinePct} + +
+
+ `.trim(); + }, + [cohort1Color, cohort2Color] + ); + + const tooltipActionsHtmlRenderer = useCallback( + (value: string) => { + if (!value) return ''; + + const actionBackground = theme.gray200; + return [ + '', + ' ', + ' Group by attribute', + ' ', + ' ', + ' Add value to filter', + ' ', + ' ', + ' Exclude value from filter', + ' ', + ' ', + ' Copy value to clipboard', + ' ', + '', + ] + .join('\n') + .trim(); }, - [] + [theme.gray200, attribute.attributeName] ); + const tooltipConfig = useAttributeBreakdownsTooltip({ + chartRef, + formatter: toolTipFormatter, + chartWidth, + actionsHtmlRenderer: tooltipActionsHtmlRenderer, + }); + const chartXAxisLabelFormatter = useCallback( (value: string): string => { const selectedSeries = seriesData[SELECTED_SERIES_NAME]; @@ -276,13 +358,7 @@ export function Chart({ ref={chartRef} autoHeightResize isGroupedByDate={false} - tooltip={{ - appendToBody: true, - trigger: 'axis', - renderMode: 'html', - valueFormatter: toolTipValueFormatter, - formatAxisLabel: toolTipFormatAxisLabel, - }} + tooltip={tooltipConfig} grid={{ left: 2, right: 8, diff --git a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx index 9f73491ce26a5c..77c852113ad5f0 100644 --- a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx +++ b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx @@ -11,7 +11,6 @@ import { } from 'sentry/views/explore/queryParams/context'; type Params = { - attributeName: string; chartRef: React.RefObject; chartWidth: number; formatter: (params: TooltipComponentFormatterCallbackParams) => string; @@ -33,6 +32,7 @@ export function useAttributeBreakdownsTooltip({ }: Params): TooltipOption { const [frozenPosition, setFrozenPosition] = useState<[number, number] | null>(null); const tooltipParamsRef = useRef(null); + const addSearchFilter = useAddSearchFilter(); const copyToClipboard = useCopyToClipboard(); const setGroupBys = useSetQueryParamsGroupBys(); @@ -145,7 +145,7 @@ export function useAttributeBreakdownsTooltip({ const offsetX = 20; let x = rawX + offsetX; - const y = rawY; + const y = rawY + 5; // flip left if it overflows chart width if (x + tooltipWidth > chartWidth) { From 238b22811aa75e93ba0238532b82dcc36171dd55 Mon Sep 17 00:00:00 2001 From: Abdullah Khan Date: Sun, 30 Nov 2025 15:47:00 -0500 Subject: [PATCH 04/12] feat(explore-attr-breakdowns): Adding comments --- .../hooks/useAttributeBreakdownsTooltip.tsx | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx index 77c852113ad5f0..e72573447b6c88 100644 --- a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx +++ b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx @@ -10,10 +10,25 @@ import { useSetQueryParamsGroupBys, } from 'sentry/views/explore/queryParams/context'; +const TOOLTIP_POSITION_X_OFFSET = 20; +const TOOLTIP_POSITION_Y_OFFSET = 5; + type Params = { + /** + * The ref to the chart component. + */ chartRef: React.RefObject; + /** + * The width of the chart. Used to dynamically position the tooltip to mitigate content fron being cut off. + */ chartWidth: number; + /** + * The formatter function to format the tooltip content. + */ formatter: (params: TooltipComponentFormatterCallbackParams) => string; + /** + * The function to render the HTML for the tooltip actions. The actions are rendered on click, anywhere on the chart. + */ actionsHtmlRenderer?: (value: string) => string; }; @@ -24,6 +39,10 @@ export enum Actions { COPY_TO_CLIPBOARD = 'copy_value_to_clipboard', } +// This hook creates a tooltip configuration for attribute breakdowns charts. +// Since echarts tooltips do not support actions out of the box, we need to handle them manually. +// It is used to freeze the tooltip position and show the tooltip actions on click, anywhere on the chart. +// So that users can intuitively enter the tooltip and click on the actions. export function useAttributeBreakdownsTooltip({ chartRef, formatter, @@ -37,6 +56,10 @@ export function useAttributeBreakdownsTooltip({ const copyToClipboard = useCopyToClipboard(); const setGroupBys = useSetQueryParamsGroupBys(); + // This effect runs on load and when the frozen position changes. + // - If frozen position is set, trigger a re-render of the tooltip with frozen position to show the + // tooltip actions (if passed in). + // - Sets up the listener for clicks anywhere on the chart to toggle the frozen tooltip state. useEffect(() => { const chartInstance: ECharts | undefined = chartRef.current?.getEchartsInstance(); if (!chartInstance) return; @@ -55,10 +78,12 @@ export function useAttributeBreakdownsTooltip({ event.preventDefault(); const pixelPoint: [number, number] = [event.offsetX, event.offsetY]; + // If the tooltip is frozen, toggle the frozen state and reset the tooltip params. if (frozenPosition) { setFrozenPosition(null); tooltipParamsRef.current = null; } else { + // If the tooltip is not frozen, set the frozen position to the current pixel point. setFrozenPosition(pixelPoint); } }; @@ -71,6 +96,8 @@ export function useAttributeBreakdownsTooltip({ }; }, [chartRef, frozenPosition]); + // This effect sets up the on click listeners for the tooltip actions. + // e-charts tooltips do not support actions out of the box, so we need to handle them manually. useEffect(() => { if (!frozenPosition) return; @@ -109,6 +136,8 @@ export function useAttributeBreakdownsTooltip({ } }; + // TODO Abdullah Khan: For now, attaching the listener to document.body + // Tried using a more specific selector causes weird race conditions, will need to investigate. document.addEventListener('click', handleClickActions); // eslint-disable-next-line consistent-return @@ -123,14 +152,29 @@ export function useAttributeBreakdownsTooltip({ renderMode: 'html', enterable: true, formatter: (params: TooltipComponentFormatterCallbackParams) => { + // If the tooltip is NOT frozen, set the tooltip params and return the formatted content, + // including the tooltip actions placeholder. if (!frozenPosition) { tooltipParamsRef.current = params; - return ( - formatter(params) + - '' - ); + + const actionsPlaceholder = ` + + `.trim(); + + return formatter(params) + actionsPlaceholder; } + // If the tooltip is frozen, return the formatted content, including the tooltip actions. const value = (Array.isArray(params) ? params[0]?.name : params.name) ?? ''; return formatter(tooltipParamsRef.current!) + actionsHtmlRenderer?.(value); }, @@ -143,13 +187,14 @@ export function useAttributeBreakdownsTooltip({ const tooltipWidth = dom?.offsetWidth ?? 0; const [rawX = 0, rawY = 0] = frozenPosition ?? point; - const offsetX = 20; - let x = rawX + offsetX; - const y = rawY + 5; + // Adding offsets to mitigate users accidentally entering the tooltip, + // when trying to hover over the chart for values. + let x = rawX + TOOLTIP_POSITION_X_OFFSET; + const y = rawY - TOOLTIP_POSITION_Y_OFFSET; - // flip left if it overflows chart width + // Flip left if it overflows chart width. Mitigates the content from being cut off. if (x + tooltipWidth > chartWidth) { - x = rawX - tooltipWidth - offsetX; + x = rawX - tooltipWidth - TOOLTIP_POSITION_X_OFFSET; } return [x, y]; From 4be431cb6e0dad70832fc9327a96aacfaa809126 Mon Sep 17 00:00:00 2001 From: Abdullah Khan Date: Sun, 30 Nov 2025 15:50:17 -0500 Subject: [PATCH 05/12] feat(explore-attr-breakdowns): Adding comments --- .../app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx index e72573447b6c88..f25833191f95db 100644 --- a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx +++ b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx @@ -190,7 +190,7 @@ export function useAttributeBreakdownsTooltip({ // Adding offsets to mitigate users accidentally entering the tooltip, // when trying to hover over the chart for values. let x = rawX + TOOLTIP_POSITION_X_OFFSET; - const y = rawY - TOOLTIP_POSITION_Y_OFFSET; + const y = rawY + TOOLTIP_POSITION_Y_OFFSET; // Flip left if it overflows chart width. Mitigates the content from being cut off. if (x + tooltipWidth > chartWidth) { From 59c2b09ae5f42ae0864f62eea9119c52afb4793a Mon Sep 17 00:00:00 2001 From: Abdullah Khan Date: Sun, 30 Nov 2025 16:04:04 -0500 Subject: [PATCH 06/12] feat(explore-attr-breakdowns): memoizing config --- .../hooks/useAttributeBreakdownsTooltip.tsx | 83 ++++++++++--------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx index f25833191f95db..b1ea12a8cf4587 100644 --- a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx +++ b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx @@ -1,4 +1,4 @@ -import {useEffect, useRef, useState} from 'react'; +import {useEffect, useMemo, useRef, useState} from 'react'; import type {ECharts, TooltipComponentFormatterCallbackParams} from 'echarts'; import type {TooltipOption} from 'sentry/components/charts/baseChart'; @@ -146,18 +146,19 @@ export function useAttributeBreakdownsTooltip({ }; }, [frozenPosition, copyToClipboard, addSearchFilter, setGroupBys]); - const tooltipConfig: TooltipOption = { - trigger: 'axis', - appendToBody: true, - renderMode: 'html', - enterable: true, - formatter: (params: TooltipComponentFormatterCallbackParams) => { - // If the tooltip is NOT frozen, set the tooltip params and return the formatted content, - // including the tooltip actions placeholder. - if (!frozenPosition) { - tooltipParamsRef.current = params; - - const actionsPlaceholder = ` + const tooltipConfig: TooltipOption = useMemo( + () => ({ + trigger: 'axis', + appendToBody: true, + renderMode: 'html', + enterable: true, + formatter: (params: TooltipComponentFormatterCallbackParams) => { + // If the tooltip is NOT frozen, set the tooltip params and return the formatted content, + // including the tooltip actions placeholder. + if (!frozenPosition) { + tooltipParamsRef.current = params; + + const actionsPlaceholder = `