diff --git a/static/app/views/dashboards/widgets/heatMapWidget/settings.ts b/static/app/views/dashboards/widgets/heatMapWidget/settings.ts index b0d08f59696352..ced6b27a8f9b9e 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 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_X_BUCKET = 15; + +/** + * 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..7237634b137512 --- /dev/null +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.spec.tsx @@ -0,0 +1,53 @@ +import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; + +import {getHeatmapXAxisBucketInterval} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval'; + +function makeSelection(period: string) { + return PageFiltersFixture({datetime: {period, start: null, end: null, utc: null}}); +} + +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('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('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('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 new file mode 100644 index 00000000000000..1083fc94b47697 --- /dev/null +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval.tsx @@ -0,0 +1,26 @@ +import {getDiffInMinutes} from 'sentry/components/charts/utils'; +import type {PageFilters} from 'sentry/types/core'; +import {millisecondsToClosestInterval} from 'sentry/utils/duration/millisecondsToInterval'; +import {PIXELS_PER_X_BUCKET} from 'sentry/views/dashboards/widgets/heatMapWidget/settings'; + +/** + * 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, + 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; +} 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..20383577b5c890 --- /dev/null +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.spec.tsx @@ -0,0 +1,29 @@ +import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; + +import {getHeatmapYAxisBucketCount} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount'; + +function makeSelection(period: string) { + return PageFiltersFixture({datetime: {period, start: null, end: null, utc: null}}); +} + +describe('getHeatmapYAxisBucketCount()', () => { + 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('returns a positive bucket count once the container is measured', () => { + expect( + getHeatmapYAxisBucketCount(makeSelection('24h'), '1h', 800) + ).toBeGreaterThanOrEqual(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 new file mode 100644 index 00000000000000..72f21e7e51872a --- /dev/null +++ b/static/app/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount.tsx @@ -0,0 +1,28 @@ +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 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( + 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))); +} 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..7db1286be8a73e 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'; @@ -55,11 +52,7 @@ 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 { - mapMetricUnitToFieldType, - updateVisualizeYAxis, -} from 'sentry/views/explore/metrics/utils'; +import {updateVisualizeYAxis} from 'sentry/views/explore/metrics/utils'; import { useQueryParamsAggregateSortBys, useQueryParamsMode, @@ -74,7 +67,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 +170,17 @@ export function MetricPanel({ const chartContainerRef = useRef(null); const {width: chartContainerWidth} = useDimensions({elementRef: chartContainerRef}); - const xBucketInterval = getHeatmapXBucketInterval( + const xBucketInterval = getHeatmapXAxisBucketInterval( selection, interval, chartContainerWidth, intervalOptions ); - const yBuckets = getHeatmapYBuckets(selection, xBucketInterval, chartContainerWidth); + const yBuckets = getHeatmapYAxisBucketCount( + selection, + xBucketInterval, + chartContainerWidth + ); const heatmapApiOptions = metricHeatmapApiOptions({ traceMetric, @@ -390,77 +386,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 ) : (