From 4f6ec8393c3a2820f127a2f08cab8ae958ba7632 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Wed, 24 Jun 2026 12:13:32 -0700 Subject: [PATCH] fix(detectors): Anchor non-percentage chart y-axis at zero Previously, the detector chart y-axis min value was always calculated with padding from the lowest data point, which could make the axis start far from zero for clustered data. This made small variations appear exaggerated. Now non-percentage metrics anchor the y-axis at 0 for a more accurate visual representation. Percentage metrics still zoom into the data range since pinning to 0 would hide small but meaningful variations (e.g. 90% to 100%). --- .../utils/useDetectorChartAxisBounds.spec.tsx | 53 +++++++++++++++++++ .../utils/useDetectorChartAxisBounds.tsx | 14 +++-- 2 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 static/app/views/detectors/components/details/metric/utils/useDetectorChartAxisBounds.spec.tsx diff --git a/static/app/views/detectors/components/details/metric/utils/useDetectorChartAxisBounds.spec.tsx b/static/app/views/detectors/components/details/metric/utils/useDetectorChartAxisBounds.spec.tsx new file mode 100644 index 00000000000000..3015a705e5358d --- /dev/null +++ b/static/app/views/detectors/components/details/metric/utils/useDetectorChartAxisBounds.spec.tsx @@ -0,0 +1,53 @@ +import {renderHook} from 'sentry-test/reactTestingLibrary'; + +import type {Series} from 'sentry/types/echarts'; +import {useDetectorChartAxisBounds} from 'sentry/views/detectors/components/details/metric/utils/useDetectorChartAxisBounds'; + +function makeSeries(values: number[]): Series { + return { + seriesName: 'test', + data: values.map((value, i) => ({name: i, value})), + }; +} + +describe('useDetectorChartAxisBounds', () => { + it('anchors the min at 0 and pads the max for non-percentage metrics', () => { + const {result} = renderHook(() => + useDetectorChartAxisBounds({ + series: [makeSeries([450, 500, 550])], + thresholdMaxValue: 300, + aggregate: 'count()', + }) + ); + + // min is anchored at 0 rather than zooming into the clustered data, max gets 10% padding + expect(result.current).toEqual({minValue: 0, maxValue: 605}); + }); + + it('uses the threshold as the max without padding when it exceeds the data', () => { + const {result} = renderHook(() => + useDetectorChartAxisBounds({ + series: [makeSeries([100])], + thresholdMaxValue: 200, + aggregate: 'count()', + }) + ); + + // threshold is the ceiling, so it's used as-is so the threshold line sits at the top edge + expect(result.current.maxValue).toBe(200); + }); + + it('zooms the min into the data range and caps the max at 1 for percentage metrics', () => { + const {result} = renderHook(() => + useDetectorChartAxisBounds({ + series: [makeSeries([0.9, 0.95, 1])], + thresholdMaxValue: 0.5, + aggregate: 'failure_rate()', + }) + ); + + // min zooms to seriesMin - 10% padding, max is capped at 100% + expect(result.current.minValue).toBeCloseTo(0.81); + expect(result.current.maxValue).toBe(1); + }); +}); diff --git a/static/app/views/detectors/components/details/metric/utils/useDetectorChartAxisBounds.tsx b/static/app/views/detectors/components/details/metric/utils/useDetectorChartAxisBounds.tsx index 2e75d806ee0e40..c4ef3b2b7f84ea 100644 --- a/static/app/views/detectors/components/details/metric/utils/useDetectorChartAxisBounds.tsx +++ b/static/app/views/detectors/components/details/metric/utils/useDetectorChartAxisBounds.tsx @@ -41,6 +41,8 @@ export function useDetectorChartAxisBounds({ const seriesMax = Math.max(...allSeriesValues); const seriesMin = Math.min(...allSeriesValues); + const isPercentage = aggregate && aggregateOutputType(aggregate) === 'percentage'; + // Determine the max value: use threshold if it's higher than data, otherwise add padding to data let maxValue: number; if (thresholdMaxValue && thresholdMaxValue >= seriesMax) { @@ -53,14 +55,18 @@ export function useDetectorChartAxisBounds({ } // Cap percentage metrics at 100% (1.0 in 0-1 scale) - const isPercentage = aggregate && aggregateOutputType(aggregate) === 'percentage'; if (isPercentage && maxValue > 1) { maxValue = 1; } - // Add padding to min value - const minPadding = seriesMin * 0.1; - const minValue = Math.max(0, seriesMin - minPadding); + // For percentage metrics, zoom into the data range (e.g. 90% -> 100%) since + // pinning to 0 hides small variations. For all other metrics, anchor the + // axis at 0. + let minValue = 0; + if (isPercentage) { + const minPadding = seriesMin * 0.1; + minValue = Math.max(0, seriesMin - minPadding); + } return { maxValue,