From 5b180ccc93155decdc61688204a64af9e9d19f5e Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:05:34 -0400 Subject: [PATCH 1/4] ref(dashboards): Extract shared heat map chart utilities Move Explore's heat map helpers into a shared heatMapWidget location so the upcoming dashboards heat map widget can reuse them: the X/Y bucket-sizing helpers (getHeatmapXAxisBucketInterval, getHeatmapYAxisBucketCount), the metric-unit patch (mergeMetricUnit), and heat map settings (PIXELS_PER_BUCKET, HEATMAP_Z_AXIS_SCALE). The Explore metric panel and heat map now import them. Behavior-preserving refactor split from the larger heat map dashboard widget work. Refs DAIN-1654 Co-Authored-By: Claude Opus 4.8 --- .../widgets/heatMapWidget/settings.ts | 13 +++ .../getHeatmapXAxisBucketInterval.spec.tsx | 40 ++++++++ .../utils/getHeatmapXAxisBucketInterval.tsx | 30 ++++++ .../utils/getHeatmapYAxisBucketCount.spec.tsx | 16 +++ .../utils/getHeatmapYAxisBucketCount.tsx | 15 +++ .../heatMapWidget/utils/mergeMetricUnit.tsx | 33 +++++++ .../explore/metrics/metricPanel/index.tsx | 98 ++----------------- .../views/explore/metrics/metricsHeatMap.tsx | 3 +- 8 files changed, 155 insertions(+), 93 deletions(-) create mode 100644 static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.spec.tsx create mode 100644 static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.tsx create mode 100644 static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.spec.tsx create mode 100644 static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.tsx create mode 100644 static/app/views/dashboards/widgets/heatMapWidget/utils/mergeMetricUnit.tsx diff --git a/static/app/views/dashboards/widgets/heatMapWidget/settings.ts b/static/app/views/dashboards/widgets/heatMapWidget/settings.ts index b0d08f59696352..86e9597a783a5f 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/settings.ts +++ b/static/app/views/dashboards/widgets/heatMapWidget/settings.ts @@ -14,3 +14,16 @@ export const HEATMAP_COLORS = [ '#921178', '#990056', ] as const; + +/** + * Target size, in pixels, of a single heat map bucket along each axis. Both the + * X-axis (time) interval and the Y-axis bucket count are chosen so that cells + * are roughly this size, keeping them approximately square. + */ +export const PIXELS_PER_BUCKET = 10; + +/** + * Scale used for the heat map's Z axis (the cell color). A logarithmic scale + * handles the wide range of counts better than a linear one. + */ +export const HEATMAP_Z_AXIS_SCALE = 'log' as const; diff --git a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.spec.tsx b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.spec.tsx new file mode 100644 index 00000000000000..06545bead51d91 --- /dev/null +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.spec.tsx @@ -0,0 +1,40 @@ +import type {PageFilters} from 'sentry/types/core'; +import {getIntervalOptionsForPageFilter} from 'sentry/utils/useChartInterval'; +import {getHeatmapXAxisBucketInterval} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval'; + +function makeSelection(period: string): PageFilters { + return { + projects: [], + environments: [], + datetime: {period, start: null, end: null, utc: null}, + }; +} + +function optionValues(period: string) { + return getIntervalOptionsForPageFilter(makeSelection(period).datetime).map( + option => option.value + ); +} + +describe('getHeatmapXAxisBucketInterval()', () => { + it('falls back to the largest available interval when the width is 0', () => { + const options = optionValues('24h'); + expect(getHeatmapXAxisBucketInterval(makeSelection('24h'), 0)).toBe( + options[options.length - 1] + ); + }); + + it('picks a larger interval as the container gets narrower', () => { + const options = optionValues('24h'); + const wide = getHeatmapXAxisBucketInterval(makeSelection('24h'), 1200); + const narrow = getHeatmapXAxisBucketInterval(makeSelection('24h'), 200); + // A narrower container fits fewer columns, so each bucket spans more time. + expect(options.indexOf(narrow)).toBeGreaterThanOrEqual(options.indexOf(wide)); + }); + + it('only returns intervals available for the selection', () => { + const options = optionValues('24h'); + const result = getHeatmapXAxisBucketInterval(makeSelection('24h'), 724); + expect(options).toContain(result); + }); +}); diff --git a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.tsx b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.tsx new file mode 100644 index 00000000000000..0a22d049ffa21b --- /dev/null +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.tsx @@ -0,0 +1,30 @@ +import {getDiffInMinutes} from 'sentry/components/charts/utils'; +import type {PageFilters} from 'sentry/types/core'; +import {millisecondsToClosestInterval} from 'sentry/utils/duration/millisecondsToInterval'; +import {getIntervalOptionsForPageFilter} from 'sentry/utils/useChartInterval'; +import {PIXELS_PER_BUCKET} from 'sentry/views/dashboards/widgets/heatMapWidget/settings'; + +/** + * Computes the X-axis (time) bucket interval for the heatmap API. We target + * ~`PIXELS_PER_BUCKET`-wide columns for the given width and snap that to the + * closest interval the current date range supports. Falls back to the largest + * available interval (e.g. before the container has been measured). + */ +export function getHeatmapXAxisBucketInterval( + selection: PageFilters, + chartContainerWidth: number +): string { + const intervalOptions = getIntervalOptionsForPageFilter(selection.datetime).map( + option => option.value + ); + const timeRangeInMs = getDiffInMinutes(selection.datetime) * 60 * 1000; + const msPerXBucket = Math.round( + timeRangeInMs / (chartContainerWidth / PIXELS_PER_BUCKET) + ); + + return ( + millisecondsToClosestInterval(msPerXBucket, intervalOptions) ?? + intervalOptions[intervalOptions.length - 1] ?? + '' + ); +} diff --git a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.spec.tsx b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.spec.tsx new file mode 100644 index 00000000000000..e82f8932d7307f --- /dev/null +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.spec.tsx @@ -0,0 +1,16 @@ +import {getHeatmapYAxisBucketCount} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount'; + +describe('getHeatmapYAxisBucketCount()', () => { + it('returns 0 when the container has no height', () => { + expect(getHeatmapYAxisBucketCount(0)).toBe(0); + }); + + it('divides the container height by the target bucket size', () => { + // 360 / 10 = 36 + expect(getHeatmapYAxisBucketCount(360)).toBe(36); + }); + + it('never returns fewer than 1 bucket when there is height', () => { + expect(getHeatmapYAxisBucketCount(5)).toBe(1); + }); +}); diff --git a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.tsx b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.tsx new file mode 100644 index 00000000000000..3b0e045420c54d --- /dev/null +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.tsx @@ -0,0 +1,15 @@ +import {PIXELS_PER_BUCKET} from 'sentry/views/dashboards/widgets/heatMapWidget/settings'; + +/** + * Computes the number of Y-axis buckets for the heatmap API. The X-axis + * interval already targets ~`PIXELS_PER_BUCKET`-wide columns, so we size the + * Y axis the same way — dividing the container height by that target — to keep + * cells roughly square. + */ +export function getHeatmapYAxisBucketCount(chartContainerHeight: number): number { + if (chartContainerHeight <= 0) { + return 0; + } + + return Math.max(1, Math.round(chartContainerHeight / PIXELS_PER_BUCKET)); +} diff --git a/static/app/views/dashboards/widgets/heatMapWidget/utils/mergeMetricUnit.tsx b/static/app/views/dashboards/widgets/heatMapWidget/utils/mergeMetricUnit.tsx new file mode 100644 index 00000000000000..4776bde313ecbd --- /dev/null +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/mergeMetricUnit.tsx @@ -0,0 +1,33 @@ +import type {DataUnit} from 'sentry/utils/discover/fields'; +import type {HeatMapSeries} from 'sentry/views/dashboards/widgets/common/types'; +import {mapMetricUnitToFieldType} from 'sentry/views/explore/metrics/utils'; + +/** + * The heatmap API returns the Y axis using the generic `value` field, so the + * response meta carries no metric unit. Patch the Y axis with the selected + * metric's unit/type so the chart formats values correctly (e.g. durations, + * sizes) instead of rendering raw numbers. + * + * Ideally the backend would return the metric's unit in the response meta and + * this client-side patch wouldn't be needed. + */ +export function mergeMetricUnit( + series: HeatMapSeries, + metricUnit: string | undefined +): HeatMapSeries { + const {fieldType, unit} = mapMetricUnitToFieldType(metricUnit); + if (!unit) { + return series; + } + return { + ...series, + meta: { + ...series.meta, + yAxis: { + ...series.meta.yAxis, + valueType: fieldType, + valueUnit: unit as DataUnit, + }, + }, + }; +} diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 95338df1e16fd1..9b8467b3532a2b 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -8,24 +8,21 @@ import {Container, Grid, Stack} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {Text} from '@sentry/scraps/text'; -import {getDiffInMinutes} from 'sentry/components/charts/utils'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {Panel} from 'sentry/components/panels/panel'; import {PanelBody} from 'sentry/components/panels/panelBody'; import {Placeholder} from 'sentry/components/placeholder'; import {IconClock, IconGraph} from 'sentry/icons'; import {t} from 'sentry/locale'; -import type {PageFilters} from 'sentry/types/core'; -import type {DataUnit} from 'sentry/utils/discover/fields'; -import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds'; -import {millisecondsToClosestInterval} from 'sentry/utils/duration/millisecondsToInterval'; import { ChartIntervalUnspecifiedStrategy, useChartInterval, } from 'sentry/utils/useChartInterval'; import {useDimensions} from 'sentry/utils/useDimensions'; import {useOrganization} from 'sentry/utils/useOrganization'; -import type {HeatMapSeries} from 'sentry/views/dashboards/widgets/common/types'; +import {getHeatmapXAxisBucketInterval} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval'; +import {getHeatmapYAxisBucketCount} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount'; +import {mergeMetricUnit} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/mergeMetricUnit'; import {EXPLORE_FIVE_MIN_STALE_TIME} from 'sentry/views/explore/constants'; import {useMetricsPanelAnalytics} from 'sentry/views/explore/hooks/useAnalytics'; import {useMetricOptions} from 'sentry/views/explore/hooks/useMetricOptions'; @@ -56,10 +53,7 @@ import { } from 'sentry/views/explore/metrics/metricsQueryParams'; import {MetricToolbar} from 'sentry/views/explore/metrics/metricToolbar'; import {STACKED_GRAPH_HEIGHT} from 'sentry/views/explore/metrics/settings'; -import { - mapMetricUnitToFieldType, - updateVisualizeYAxis, -} from 'sentry/views/explore/metrics/utils'; +import {updateVisualizeYAxis} from 'sentry/views/explore/metrics/utils'; import { useQueryParamsAggregateSortBys, useQueryParamsMode, @@ -74,7 +68,6 @@ import {ChartType} from 'sentry/views/insights/common/components/chart'; const RESULT_LIMIT = 50; const TWO_MINUTE_DELAY = 120; -const PIXELS_PER_X_BUCKET = 15; const CHART_TYPE_TO_ICON: Record = { [ChartType.LINE]: 'line', @@ -178,13 +171,8 @@ export function MetricPanel({ const chartContainerRef = useRef(null); const {width: chartContainerWidth} = useDimensions({elementRef: chartContainerRef}); - const xBucketInterval = getHeatmapXBucketInterval( - selection, - interval, - chartContainerWidth, - intervalOptions - ); - const yBuckets = getHeatmapYBuckets(selection, xBucketInterval, chartContainerWidth); + const xBucketInterval = getHeatmapXAxisBucketInterval(selection, chartContainerWidth); + const yBuckets = getHeatmapYAxisBucketCount(STACKED_GRAPH_HEIGHT); const heatmapApiOptions = metricHeatmapApiOptions({ traceMetric, @@ -390,77 +378,3 @@ function DnDPlaceholder({ ); } - -/** - * Computes the number of Y-axis buckets for the heatmap API so that cells - * are roughly square. The X-axis bucket count comes from the time range - * divided by the selected interval. We derive Y buckets by scaling - * xBuckets by the container's height/width aspect ratio. - */ -function getHeatmapYBuckets( - selection: PageFilters, - interval: string, - chartContainerWidth: number -): number { - const timeRangeInMs = getDiffInMinutes(selection.datetime) * 60 * 1000; - const intervalInMs = intervalToMilliseconds(interval); - if (intervalInMs <= 0 || chartContainerWidth <= 0) { - return 0; - } - const xBuckets = Math.round(timeRangeInMs / intervalInMs); - if (xBuckets <= 0) { - return 0; - } - - return Math.max(1, Math.round(xBuckets * (STACKED_GRAPH_HEIGHT / chartContainerWidth))); -} - -/** - * Computes the X-axis bucket interval for the heatmap API. - * The X-axis bucket interval is derived from the container width and the number of - * pixels per X bucket. - */ -function getHeatmapXBucketInterval( - selection: PageFilters, - interval: string, - chartContainerWidth: number, - intervalOptions: Array<{label: string; value: string}> -): string { - const timeRangeInMs = getDiffInMinutes(selection.datetime) * 60 * 1000; - const msPerXBucket = Math.round( - timeRangeInMs / (chartContainerWidth / PIXELS_PER_X_BUCKET) - ); - const xBucketInterval = millisecondsToClosestInterval( - msPerXBucket, - intervalOptions.map(option => option.value) - ); - return xBucketInterval || interval; -} - -/** - * The heatmap API response doesn't include the metric unit because the - * query uses the generic `value` field. This function patches the Y-axis - * meta with the known unit from the selected trace metric so the - * visualization can format axis labels correctly (e.g. "1.5 KB" instead - * of "1500"). - */ -function mergeMetricUnit( - series: HeatMapSeries, - metricUnit: string | undefined -): HeatMapSeries { - const {fieldType, unit} = mapMetricUnitToFieldType(metricUnit); - if (!unit) { - return series; - } - return { - ...series, - meta: { - ...series.meta, - yAxis: { - ...series.meta.yAxis, - valueType: fieldType, - valueUnit: unit as DataUnit, - }, - }, - }; -} diff --git a/static/app/views/explore/metrics/metricsHeatMap.tsx b/static/app/views/explore/metrics/metricsHeatMap.tsx index ddc1c516debce0..a710c5773e068e 100644 --- a/static/app/views/explore/metrics/metricsHeatMap.tsx +++ b/static/app/views/explore/metrics/metricsHeatMap.tsx @@ -8,6 +8,7 @@ import type {HeatMapSeries} from 'sentry/views/dashboards/widgets/common/types'; import {WidgetLoadingPanel} from 'sentry/views/dashboards/widgets/common/widgetLoadingPanel'; import {HeatMapWidgetVisualization} from 'sentry/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization'; import {HeatMap} from 'sentry/views/dashboards/widgets/heatMapWidget/plottables/heatMap'; +import {HEATMAP_Z_AXIS_SCALE} from 'sentry/views/dashboards/widgets/heatMapWidget/settings'; import {Widget} from 'sentry/views/dashboards/widgets/widget/widget'; import {WidgetWrapper} from 'sentry/views/explore/metrics/metricGraph/styles'; import { @@ -88,7 +89,7 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr ) : ( From 6033c951584ec9815cd889b7b4d24ad6f43515a2 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:08:41 -0400 Subject: [PATCH 2/4] Update settings.ts --- static/app/views/dashboards/widgets/heatMapWidget/settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/dashboards/widgets/heatMapWidget/settings.ts b/static/app/views/dashboards/widgets/heatMapWidget/settings.ts index 86e9597a783a5f..d6f7eec3908dd8 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/settings.ts +++ b/static/app/views/dashboards/widgets/heatMapWidget/settings.ts @@ -20,7 +20,7 @@ export const HEATMAP_COLORS = [ * X-axis (time) interval and the Y-axis bucket count are chosen so that cells * are roughly this size, keeping them approximately square. */ -export const PIXELS_PER_BUCKET = 10; +export const PIXELS_PER_BUCKET = 15; /** * Scale used for the heat map's Z axis (the cell color). A logarithmic scale From b6bf50dafe1474ffa1e1d8f0a4c212f3d4283c06 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:44:36 -0400 Subject: [PATCH 3/4] ref(dashboards): Make heat map util extraction behavior-preserving Move Explore's existing heat map helpers verbatim instead of rewriting them. The previous version of this branch simplified the helpers while relocating them, which silently changed Explore's behavior (it dropped the 1d interval option for long ranges and replaced the width-gated Y-bucket math). Restore the original getHeatmapXBucketInterval / getHeatmapYBuckets bodies and signatures (and PIXELS_PER_X_BUCKET = 15) under the renamed shared files so this is a true no-op refactor for Explore. Refs DAIN-1654 --- .../widgets/heatMapWidget/settings.ts | 8 +-- .../getHeatmapXAxisBucketInterval.spec.tsx | 56 ++++++++++++------- .../utils/getHeatmapXAxisBucketInterval.tsx | 28 ++++------ .../utils/getHeatmapYAxisBucketCount.spec.tsx | 30 +++++++--- .../utils/getHeatmapYAxisBucketCount.tsx | 29 +++++++--- .../explore/metrics/metricPanel/index.tsx | 14 ++++- 6 files changed, 107 insertions(+), 58 deletions(-) diff --git a/static/app/views/dashboards/widgets/heatMapWidget/settings.ts b/static/app/views/dashboards/widgets/heatMapWidget/settings.ts index 86e9597a783a5f..ced6b27a8f9b9e 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/settings.ts +++ b/static/app/views/dashboards/widgets/heatMapWidget/settings.ts @@ -16,11 +16,11 @@ export const HEATMAP_COLORS = [ ] as const; /** - * Target size, in pixels, of a single heat map bucket along each axis. Both the - * X-axis (time) interval and the Y-axis bucket count are chosen so that cells - * are roughly this size, keeping them approximately square. + * Target width, in pixels, of a single heat map X-axis (time) bucket. The + * interval is chosen so that columns are roughly this wide for the rendered + * container width. */ -export const PIXELS_PER_BUCKET = 10; +export const PIXELS_PER_X_BUCKET = 15; /** * Scale used for the heat map's Z axis (the cell color). A logarithmic scale diff --git a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.spec.tsx b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.spec.tsx index 06545bead51d91..be5447b268349d 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.spec.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.spec.tsx @@ -1,5 +1,4 @@ import type {PageFilters} from 'sentry/types/core'; -import {getIntervalOptionsForPageFilter} from 'sentry/utils/useChartInterval'; import {getHeatmapXAxisBucketInterval} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval'; function makeSelection(period: string): PageFilters { @@ -10,31 +9,48 @@ function makeSelection(period: string): PageFilters { }; } -function optionValues(period: string) { - return getIntervalOptionsForPageFilter(makeSelection(period).datetime).map( - option => option.value - ); -} +const INTERVAL_OPTIONS = [ + {value: '1m', label: '1 minute'}, + {value: '5m', label: '5 minutes'}, + {value: '1h', label: '1 hour'}, + {value: '12h', label: '12 hours'}, + {value: '1d', label: '1 day'}, +]; describe('getHeatmapXAxisBucketInterval()', () => { - it('falls back to the largest available interval when the width is 0', () => { - const options = optionValues('24h'); - expect(getHeatmapXAxisBucketInterval(makeSelection('24h'), 0)).toBe( - options[options.length - 1] + it('snaps to a wider interval as the container gets narrower', () => { + const values = INTERVAL_OPTIONS.map(option => option.value); + const wide = getHeatmapXAxisBucketInterval( + makeSelection('24h'), + '1h', + 1200, + INTERVAL_OPTIONS + ); + const narrow = getHeatmapXAxisBucketInterval( + makeSelection('24h'), + '1h', + 200, + INTERVAL_OPTIONS ); + // A narrower container fits fewer columns, so each bucket spans more time. + expect(values.indexOf(narrow)).toBeGreaterThanOrEqual(values.indexOf(wide)); }); - it('picks a larger interval as the container gets narrower', () => { - const options = optionValues('24h'); - const wide = getHeatmapXAxisBucketInterval(makeSelection('24h'), 1200); - const narrow = getHeatmapXAxisBucketInterval(makeSelection('24h'), 200); - // A narrower container fits fewer columns, so each bucket spans more time. - expect(options.indexOf(narrow)).toBeGreaterThanOrEqual(options.indexOf(wide)); + it('only returns one of the provided interval options', () => { + const result = getHeatmapXAxisBucketInterval( + makeSelection('24h'), + '1h', + 724, + INTERVAL_OPTIONS + ); + expect(INTERVAL_OPTIONS.map(option => option.value)).toContain(result); }); - it('only returns intervals available for the selection', () => { - const options = optionValues('24h'); - const result = getHeatmapXAxisBucketInterval(makeSelection('24h'), 724); - expect(options).toContain(result); + it('falls back to the given interval before the container has been measured', () => { + // Width 0 yields no finite bucket size, so there is nothing to snap to and + // the currently selected interval is kept. + expect( + getHeatmapXAxisBucketInterval(makeSelection('24h'), '1h', 0, INTERVAL_OPTIONS) + ).toBe('1h'); }); }); diff --git a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.tsx b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.tsx index 0a22d049ffa21b..1083fc94b47697 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.tsx @@ -1,30 +1,26 @@ import {getDiffInMinutes} from 'sentry/components/charts/utils'; import type {PageFilters} from 'sentry/types/core'; import {millisecondsToClosestInterval} from 'sentry/utils/duration/millisecondsToInterval'; -import {getIntervalOptionsForPageFilter} from 'sentry/utils/useChartInterval'; -import {PIXELS_PER_BUCKET} from 'sentry/views/dashboards/widgets/heatMapWidget/settings'; +import {PIXELS_PER_X_BUCKET} from 'sentry/views/dashboards/widgets/heatMapWidget/settings'; /** - * Computes the X-axis (time) bucket interval for the heatmap API. We target - * ~`PIXELS_PER_BUCKET`-wide columns for the given width and snap that to the - * closest interval the current date range supports. Falls back to the largest - * available interval (e.g. before the container has been measured). + * Computes the X-axis bucket interval for the heatmap API. + * The X-axis bucket interval is derived from the container width and the number of + * pixels per X bucket. */ export function getHeatmapXAxisBucketInterval( selection: PageFilters, - chartContainerWidth: number + interval: string, + chartContainerWidth: number, + intervalOptions: Array<{label: string; value: string}> ): string { - const intervalOptions = getIntervalOptionsForPageFilter(selection.datetime).map( - option => option.value - ); const timeRangeInMs = getDiffInMinutes(selection.datetime) * 60 * 1000; const msPerXBucket = Math.round( - timeRangeInMs / (chartContainerWidth / PIXELS_PER_BUCKET) + timeRangeInMs / (chartContainerWidth / PIXELS_PER_X_BUCKET) ); - - return ( - millisecondsToClosestInterval(msPerXBucket, intervalOptions) ?? - intervalOptions[intervalOptions.length - 1] ?? - '' + const xBucketInterval = millisecondsToClosestInterval( + msPerXBucket, + intervalOptions.map(option => option.value) ); + return xBucketInterval || interval; } diff --git a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.spec.tsx b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.spec.tsx index e82f8932d7307f..ee6535a0639ced 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.spec.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.spec.tsx @@ -1,16 +1,32 @@ +import type {PageFilters} from 'sentry/types/core'; import {getHeatmapYAxisBucketCount} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount'; +function makeSelection(period: string): PageFilters { + return { + projects: [], + environments: [], + datetime: {period, start: null, end: null, utc: null}, + }; +} + describe('getHeatmapYAxisBucketCount()', () => { - it('returns 0 when the container has no height', () => { - expect(getHeatmapYAxisBucketCount(0)).toBe(0); + it('returns 0 before the container has been measured', () => { + expect(getHeatmapYAxisBucketCount(makeSelection('24h'), '1h', 0)).toBe(0); + }); + + it('returns 0 for a non-positive interval', () => { + expect(getHeatmapYAxisBucketCount(makeSelection('24h'), '0', 800)).toBe(0); }); - it('divides the container height by the target bucket size', () => { - // 360 / 10 = 36 - expect(getHeatmapYAxisBucketCount(360)).toBe(36); + it('returns a positive bucket count once the container is measured', () => { + expect( + getHeatmapYAxisBucketCount(makeSelection('24h'), '1h', 800) + ).toBeGreaterThanOrEqual(1); }); - it('never returns fewer than 1 bucket when there is height', () => { - expect(getHeatmapYAxisBucketCount(5)).toBe(1); + it('fits fewer Y buckets into a wider container', () => { + const narrow = getHeatmapYAxisBucketCount(makeSelection('24h'), '1h', 400); + const wide = getHeatmapYAxisBucketCount(makeSelection('24h'), '1h', 1600); + expect(wide).toBeLessThanOrEqual(narrow); }); }); diff --git a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.tsx b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.tsx index 3b0e045420c54d..72f21e7e51872a 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.tsx @@ -1,15 +1,28 @@ -import {PIXELS_PER_BUCKET} from 'sentry/views/dashboards/widgets/heatMapWidget/settings'; +import {getDiffInMinutes} from 'sentry/components/charts/utils'; +import type {PageFilters} from 'sentry/types/core'; +import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds'; +import {STACKED_GRAPH_HEIGHT} from 'sentry/views/explore/metrics/settings'; /** - * Computes the number of Y-axis buckets for the heatmap API. The X-axis - * interval already targets ~`PIXELS_PER_BUCKET`-wide columns, so we size the - * Y axis the same way — dividing the container height by that target — to keep - * cells roughly square. + * Computes the number of Y-axis buckets for the heatmap API so that cells + * are roughly square. The X-axis bucket count comes from the time range + * divided by the selected interval. We derive Y buckets by scaling + * xBuckets by the container's height/width aspect ratio. */ -export function getHeatmapYAxisBucketCount(chartContainerHeight: number): number { - if (chartContainerHeight <= 0) { +export function getHeatmapYAxisBucketCount( + selection: PageFilters, + interval: string, + chartContainerWidth: number +): number { + const timeRangeInMs = getDiffInMinutes(selection.datetime) * 60 * 1000; + const intervalInMs = intervalToMilliseconds(interval); + if (intervalInMs <= 0 || chartContainerWidth <= 0) { + return 0; + } + const xBuckets = Math.round(timeRangeInMs / intervalInMs); + if (xBuckets <= 0) { return 0; } - return Math.max(1, Math.round(chartContainerHeight / PIXELS_PER_BUCKET)); + return Math.max(1, Math.round(xBuckets * (STACKED_GRAPH_HEIGHT / chartContainerWidth))); } diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 9b8467b3532a2b..7db1286be8a73e 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -52,7 +52,6 @@ import { useSetMetricVisualizes, } from 'sentry/views/explore/metrics/metricsQueryParams'; import {MetricToolbar} from 'sentry/views/explore/metrics/metricToolbar'; -import {STACKED_GRAPH_HEIGHT} from 'sentry/views/explore/metrics/settings'; import {updateVisualizeYAxis} from 'sentry/views/explore/metrics/utils'; import { useQueryParamsAggregateSortBys, @@ -171,8 +170,17 @@ export function MetricPanel({ const chartContainerRef = useRef(null); const {width: chartContainerWidth} = useDimensions({elementRef: chartContainerRef}); - const xBucketInterval = getHeatmapXAxisBucketInterval(selection, chartContainerWidth); - const yBuckets = getHeatmapYAxisBucketCount(STACKED_GRAPH_HEIGHT); + const xBucketInterval = getHeatmapXAxisBucketInterval( + selection, + interval, + chartContainerWidth, + intervalOptions + ); + const yBuckets = getHeatmapYAxisBucketCount( + selection, + xBucketInterval, + chartContainerWidth + ); const heatmapApiOptions = metricHeatmapApiOptions({ traceMetric, From fc64c6be8040cfa4766867604db7763bd46ab183 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:48:37 -0400 Subject: [PATCH 4/4] test(dashboards): Use PageFiltersFixture in heat map bucket specs Refs DAIN-1654 --- .../utils/getHeatmapXAxisBucketInterval.spec.tsx | 11 ++++------- .../utils/getHeatmapYAxisBucketCount.spec.tsx | 11 ++++------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.spec.tsx b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.spec.tsx index be5447b268349d..7237634b137512 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.spec.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.spec.tsx @@ -1,12 +1,9 @@ -import type {PageFilters} from 'sentry/types/core'; +import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; + import {getHeatmapXAxisBucketInterval} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval'; -function makeSelection(period: string): PageFilters { - return { - projects: [], - environments: [], - datetime: {period, start: null, end: null, utc: null}, - }; +function makeSelection(period: string) { + return PageFiltersFixture({datetime: {period, start: null, end: null, utc: null}}); } const INTERVAL_OPTIONS = [ diff --git a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.spec.tsx b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.spec.tsx index ee6535a0639ced..20383577b5c890 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.spec.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.spec.tsx @@ -1,12 +1,9 @@ -import type {PageFilters} from 'sentry/types/core'; +import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; + import {getHeatmapYAxisBucketCount} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount'; -function makeSelection(period: string): PageFilters { - return { - projects: [], - environments: [], - datetime: {period, start: null, end: null, utc: null}, - }; +function makeSelection(period: string) { + return PageFiltersFixture({datetime: {period, start: null, end: null, utc: null}}); } describe('getHeatmapYAxisBucketCount()', () => {