From ce16bf27b7b1ee497e74c792da78ba86936e49f8 Mon Sep 17 00:00:00 2001 From: Niladri Adhikary Date: Tue, 4 Aug 2026 11:35:39 +0530 Subject: [PATCH] feat(app): show percentile context in the heatmap hover tooltip Signed-off-by: Niladri Adhikary --- .changeset/heatmap-tooltip-percentile.md | 5 ++ .../app/src/components/DBHeatmapChart.tsx | 42 ++++++++++++++ .../__tests__/DBHeatmapChart.test.ts | 55 ++++++++++++++++++- 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 .changeset/heatmap-tooltip-percentile.md diff --git a/.changeset/heatmap-tooltip-percentile.md b/.changeset/heatmap-tooltip-percentile.md new file mode 100644 index 0000000000..03354d4b9f --- /dev/null +++ b/.changeset/heatmap-tooltip-percentile.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +Show percentile context in the heatmap hover tooltip diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 08996b1ffc..a752a45db0 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -676,6 +676,36 @@ export function formatDataForHeatmap({ return [time, bucket, count]; } +/** + * Cumulative share (0-100) of events at or below each y-bucket, keyed by + * that bucket's y-value. + */ +export function computeBucketPercentiles( + data: Mode2DataArray, +): Map { + const [, ys, counts] = data; + + const bucketTotals = new Map(); + let total = 0; + for (let i = 0; i < ys.length; i++) { + bucketTotals.set(ys[i], (bucketTotals.get(ys[i]) ?? 0) + counts[i]); + total += counts[i]; + } + + const percentiles = new Map(); + if (total === 0) { + return percentiles; + } + + let cumulative = 0; + for (const [y, bucketTotal] of [...bucketTotals].sort(([a], [b]) => a - b)) { + cumulative += bucketTotal; + percentiles.set(y, (cumulative / total) * 100); + } + + return percentiles; +} + function HeatmapContainer({ config, enabled = true, @@ -1093,6 +1123,11 @@ function Heatmap({ [numberFormatKey, scaleType], ); + const bucketPercentiles = useMemo( + () => computeBucketPercentiles(data), + [data], + ); + const options: uPlot.Options = useMemo(() => { const themedSeries = buildSeriesForPalette(palette); return { @@ -1258,6 +1293,9 @@ function Heatmap({ }; }, [width, height, tickFormatter, scaleType, palette, hasFilter]); + const highlightedPercentile = + highlightedPoint && bucketPercentiles.get(highlightedPoint.yVal); + return (
Y Value: {tickFormatter(highlightedPoint.yVal)} + {highlightedPercentile != null && + ` (p${new Intl.NumberFormat('en-US', { + maximumFractionDigits: 1, + }).format(highlightedPercentile)})`}
Count Value:{' '} diff --git a/packages/app/src/components/__tests__/DBHeatmapChart.test.ts b/packages/app/src/components/__tests__/DBHeatmapChart.test.ts index 2a4ba10d11..b2382f76b1 100644 --- a/packages/app/src/components/__tests__/DBHeatmapChart.test.ts +++ b/packages/app/src/components/__tests__/DBHeatmapChart.test.ts @@ -1,4 +1,7 @@ -import { formatDataForHeatmap } from '@/components/DBHeatmapChart'; +import { + computeBucketPercentiles, + formatDataForHeatmap, +} from '@/components/DBHeatmapChart'; // CH widthBucket returns buckets 0..nBuckets+1, so each time bucket // produces nBuckets+2 grid cells. @@ -94,3 +97,53 @@ describe('formatDataForHeatmap', () => { ]); }); }); + +describe('computeBucketPercentiles', () => { + it('reports the share of events at or below each bucket, pooled across time buckets', () => { + const percentiles = computeBucketPercentiles( + formatDataForHeatmap({ + ...baseArgs, + data: [ + row(T0, 1, '3'), + row(T0, 4, '1'), + row(T1, 1, '5'), + row(T1, 2, '1'), + ], + }), + ); + + expect(percentiles).toEqual( + new Map([ + [0, 0], + [1, 80], // 3 + 5 of the 10 events, both time buckets counted + [2, 90], + [3, 90], // empty bucket carries the share of the one below it + [4, 100], + [5, 100], + ]), + ); + }); + + it('returns no percentiles when the grid holds no events', () => { + // Every cell is 0, so there is no distribution to place a value in + // the tooltip omits the percentile rather than dividing by zero. + expect( + computeBucketPercentiles(formatDataForHeatmap({ ...baseArgs, data: [] })), + ).toEqual(new Map()); + }); + + it('orders buckets by y-value rather than by the order they arrive in', () => { + const percentiles = computeBucketPercentiles([ + [0, 0], // times, unused + [5, 1], // y-values, highest first + [1, 3], // counts + ]); + + expect(percentiles).toEqual( + new Map([ + [1, 75], + [5, 100], + ]), + ); + }); +});