From 17c32c18a88d3e68aacbea9cb724a596311f04ec Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Wed, 11 Nov 2026 11:24:11 +1100 Subject: [PATCH 1/5] fix(app): keep the heatmap tooltip from overflowing its tile The hover tooltip was positioned inside the chart container and clamped assuming a 90px height. With the drag hint it is taller, so it spilled past the tile, which clipped it and added scrollbars. Render it into the body with fixed positioning, like the line chart and annotation tooltips. --- .changeset/heatmap-tooltip-portal.md | 6 + .../components/DBHeatmapChart/HeatmapPlot.tsx | 10 +- .../DBHeatmapChart/HeatmapTooltip.tsx | 162 ++++++++++-------- .../__tests__/HeatmapTooltip.test.tsx | 19 +- 4 files changed, 119 insertions(+), 78 deletions(-) create mode 100644 .changeset/heatmap-tooltip-portal.md diff --git a/.changeset/heatmap-tooltip-portal.md b/.changeset/heatmap-tooltip-portal.md new file mode 100644 index 0000000000..6521c3e0d2 --- /dev/null +++ b/.changeset/heatmap-tooltip-portal.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/app': patch +--- + +fix: heatmap hover tooltip no longer adds scrollbars or shifts the tile; it now +sits above the chart instead of inside it diff --git a/packages/app/src/components/DBHeatmapChart/HeatmapPlot.tsx b/packages/app/src/components/DBHeatmapChart/HeatmapPlot.tsx index 026b8bccd9..e95e11641a 100644 --- a/packages/app/src/components/DBHeatmapChart/HeatmapPlot.tsx +++ b/packages/app/src/components/DBHeatmapChart/HeatmapPlot.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import cx from 'classnames'; import uPlot from 'uplot'; import UplotReact from 'uplot-react'; -import { useElementSize } from '@mantine/hooks'; +import { useElementSize, useMergedRef } from '@mantine/hooks'; import { useStableValue } from '@/hooks/useStableValue'; import { NumberFormat } from '@/types'; @@ -109,6 +109,8 @@ export function HeatmapPlot({ }); const { ref, width, height } = useElementSize(); + const containerRef = useRef(null); + const mergedRef = useMergedRef(ref, containerRef); // Reapply the URL-backed selection whenever the bounds prop changes // (e.g. a fresh drag-select arrives via the round-trip through the @@ -329,7 +331,7 @@ export function HeatmapPlot({ return (
{ @@ -374,8 +376,8 @@ export function HeatmapPlot({ )} diff --git a/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx b/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx index fa0abe1761..4f2004d79b 100644 --- a/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx +++ b/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx @@ -1,5 +1,10 @@ +import { createPortal } from 'react-dom'; import { Divider, Text } from '@mantine/core'; +import { + toViewportPoint, + useChartTooltipZIndex, +} from '@/components/charts/ChartTooltip'; import { FormatTime } from '@/useFormatTime'; import type { HighlightedPoint } from './highlightDataPlugin'; @@ -29,19 +34,30 @@ const COUNT_FORMAT = new Intl.NumberFormat('en-US', { type HeatmapTooltipProps = { point: HighlightedPoint; cell: HeatmapTooltipCell; - /** Chart container size, used to keep the tooltip inside it. */ - width: number; - height: number; + /** Chart container's viewport rect; the tooltip is portaled so a tile can't clip or scroll it. */ + containerRect: DOMRect | undefined; showDragHint: boolean; }; +// shortcut: fixed estimate of the tooltip height (with drag hint), measure it if rows get added. +const TOOLTIP_MAX_HEIGHT_PX = 150; + export function HeatmapTooltip({ point, cell, - width, - height, + containerRect, showDragHint, }: HeatmapTooltipProps) { + const zIndex = useChartTooltipZIndex(); + const anchor = toViewportPoint(containerRect, { + x: point.xCoord, + y: point.yCoord, + }); + const flipLeft = point.xCoord > (containerRect?.width ?? 0) / 2; + // Fixed elements resolve against the viewport minus scrollbars, not window.inner*. + const { clientWidth: viewportWidth, clientHeight: viewportHeight } = + document.documentElement; + return ( <>
-
width / 2 - ? { - right: width - point.xCoord + 10, - } - : { - left: point.xCoord + 10, - }), - maxWidth: '50%', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap' as const, - backdropFilter: 'blur(8px)', - backgroundColor: 'var(--mantine-color-body)', - border: '1px solid var(--mantine-color-default-border)', - borderRadius: 4, - pointerEvents: 'none', - }} - > - {showDragHint && ( - <> - - Drag to Compare · Click to Clear - - - - )} -
- -
- {cell.kind === 'series' ? ( - <> -
- Series: {cell.name} -
-
- Value: {cell.formattedValue} -
- - ) : ( - <> -
- {cell.kind === 'bucket' ? ( - <> - Bucket: {cell.formattedRange} - - ) : ( - <> - Y Value: {cell.formattedY} - - )} - {cell.percentile != null && - ` (p${new Intl.NumberFormat('en-US', { - maximumFractionDigits: 1, - }).format(cell.percentile)})`} -
-
- Count Value: {COUNT_FORMAT.format(point.countVal)} -
- - )} -
+ {createPortal( +
+ {showDragHint && ( + <> + + Drag to Compare · Click to Clear + + + + )} +
+ +
+ {cell.kind === 'series' ? ( + <> +
+ Series: {cell.name} +
+
+ Value: {cell.formattedValue} +
+ + ) : ( + <> +
+ {cell.kind === 'bucket' ? ( + <> + Bucket: {cell.formattedRange} + + ) : ( + <> + Y Value: {cell.formattedY} + + )} + {cell.percentile != null && + ` (p${new Intl.NumberFormat('en-US', { + maximumFractionDigits: 1, + }).format(cell.percentile)})`} +
+
+ Count Value: {COUNT_FORMAT.format(point.countVal)} +
+ + )} +
, + document.body, + )} ); } diff --git a/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapTooltip.test.tsx b/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapTooltip.test.tsx index 28dbe42f9a..b7a93936d0 100644 --- a/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapTooltip.test.tsx +++ b/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapTooltip.test.tsx @@ -25,8 +25,7 @@ const renderTooltip = ( , @@ -90,4 +89,20 @@ describe('HeatmapTooltip', () => { renderTooltip({ showDragHint: true }); expect(screen.getByText(/Drag to Compare/)).toBeInTheDocument(); }); + + it('renders the tooltip outside the chart container, in viewport coordinates', () => { + const viewportHeight = jest + .spyOn(document.documentElement, 'clientHeight', 'get') + .mockReturnValue(1000); + const { container } = renderTooltip(); + + const tooltip = screen + .getByText('time:1000') + .closest('div[style*="position: fixed"]'); + expect(tooltip).not.toBeNull(); + expect(container.contains(tooltip)).toBe(false); + expect(tooltip?.style.top).toBe('215px'); + expect(tooltip?.style.left).toBe('120px'); + viewportHeight.mockRestore(); + }); }); From a8b54174c40b254e88b2a04b8d1dc7d5359c323f Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Wed, 11 Nov 2026 11:31:13 +1100 Subject: [PATCH 2/5] fix(app): drop the eslint-disable that breached the escape-hatch ratchet Keep the heatmap container in state through a callback ref so the tooltip reads a DOM node at render, not a ref. --- packages/app/src/components/DBHeatmapChart/HeatmapPlot.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart/HeatmapPlot.tsx b/packages/app/src/components/DBHeatmapChart/HeatmapPlot.tsx index e95e11641a..76518bc846 100644 --- a/packages/app/src/components/DBHeatmapChart/HeatmapPlot.tsx +++ b/packages/app/src/components/DBHeatmapChart/HeatmapPlot.tsx @@ -109,8 +109,8 @@ export function HeatmapPlot({ }); const { ref, width, height } = useElementSize(); - const containerRef = useRef(null); - const mergedRef = useMergedRef(ref, containerRef); + const [container, setContainer] = useState(null); + const mergedRef = useMergedRef(ref, setContainer); // Reapply the URL-backed selection whenever the bounds prop changes // (e.g. a fresh drag-select arrives via the round-trip through the @@ -376,8 +376,7 @@ export function HeatmapPlot({ )} From 2a427ac0f3a2cbb808de97a9851677eec19224d2 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Wed, 11 Nov 2026 11:32:39 +1100 Subject: [PATCH 3/5] fix(app): hide the heatmap tooltip when the page scrolls The tooltip is fixed to the viewport, so scrolling under a still cursor left it behind while the chart moved. Hide it on any scroll; the next mouse move shows it again at the right spot. Also cover the bottom clamp and right-half flip in tests. --- .../components/DBHeatmapChart/HeatmapPlot.tsx | 10 +++++++++ .../__tests__/HeatmapPlot.test.tsx | 12 ++++++++++ .../__tests__/HeatmapTooltip.test.tsx | 22 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/packages/app/src/components/DBHeatmapChart/HeatmapPlot.tsx b/packages/app/src/components/DBHeatmapChart/HeatmapPlot.tsx index 76518bc846..85d20a805a 100644 --- a/packages/app/src/components/DBHeatmapChart/HeatmapPlot.tsx +++ b/packages/app/src/components/DBHeatmapChart/HeatmapPlot.tsx @@ -73,6 +73,16 @@ export function HeatmapPlot({ HighlightedPoint | undefined >(undefined); + // The tooltip is fixed to the viewport, so a scroll under a still cursor + // would leave it behind; hide it until the next mouse move. + const hasHighlight = highlightedPoint != null; + useEffect(() => { + if (!hasHighlight) return; + const hide = () => setHighlightedPoint(undefined); + window.addEventListener('scroll', hide, { capture: true, passive: true }); + return () => window.removeEventListener('scroll', hide, { capture: true }); + }, [hasHighlight]); + // Gate tooltip display on actual mouse interaction. uPlot fires setCursor // on init (before user hovers), which would show the tooltip on page load. const mouseInsideRef = useRef(false); diff --git a/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapPlot.test.tsx b/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapPlot.test.tsx index f64baeb0ff..7f50fa4d76 100644 --- a/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapPlot.test.tsx +++ b/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapPlot.test.tsx @@ -165,4 +165,16 @@ describe('HeatmapPlot', () => { 'Count Value: 7', ); }); + + it('hides the tooltip when anything scrolls, until the next hover', () => { + renderPlot(); + fireEvent.mouseMove(screen.getByTestId('heatmap-plot')); + act(() => onPointHighlight(point)); + + fireEvent.scroll(document.body); + expect(screen.queryByText('Count Value:')).not.toBeInTheDocument(); + + act(() => onPointHighlight(point)); + expect(screen.getByText('Count Value:')).toBeInTheDocument(); + }); }); diff --git a/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapTooltip.test.tsx b/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapTooltip.test.tsx index b7a93936d0..166fb6945b 100644 --- a/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapTooltip.test.tsx +++ b/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapTooltip.test.tsx @@ -105,4 +105,26 @@ describe('HeatmapTooltip', () => { expect(tooltip?.style.left).toBe('120px'); viewportHeight.mockRestore(); }); + + it('keeps the tooltip inside the viewport near its bottom and right edges', () => { + const viewport = [ + jest + .spyOn(document.documentElement, 'clientHeight', 'get') + .mockReturnValue(400), + jest + .spyOn(document.documentElement, 'clientWidth', 'get') + .mockReturnValue(1200), + ]; + renderTooltip({ point: { ...point, xCoord: 300, yCoord: 190 } }); + + const tooltip = screen + .getByText('time:1000') + .closest('div[style*="position: fixed"]'); + // Cursor at y=390 in the viewport: the 150px tooltip is lifted to fit. + expect(tooltip?.style.top).toBe('250px'); + // Right half of the container: anchored to the left of the cursor. + expect(tooltip?.style.right).toBe('810px'); + expect(tooltip?.style.left).toBe(''); + viewport.forEach(spy => spy.mockRestore()); + }); }); From 77558d68f682ab98be7c4ae26dace31e7eb69de2 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Wed, 11 Nov 2026 11:37:39 +1100 Subject: [PATCH 4/5] fix(app): open the heatmap tooltip above the cursor in the lower half Replaces the fixed 150px height clamp, which lifted the shorter tooltip (no drag hint) well above the cursor near the bottom of the window. --- .../components/DBHeatmapChart/HeatmapTooltip.tsx | 14 ++++++-------- .../__tests__/HeatmapTooltip.test.tsx | 5 +++-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx b/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx index 4f2004d79b..0baf53cedd 100644 --- a/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx +++ b/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx @@ -39,9 +39,6 @@ type HeatmapTooltipProps = { showDragHint: boolean; }; -// shortcut: fixed estimate of the tooltip height (with drag hint), measure it if rows get added. -const TOOLTIP_MAX_HEIGHT_PX = 150; - export function HeatmapTooltip({ point, cell, @@ -53,10 +50,12 @@ export function HeatmapTooltip({ x: point.xCoord, y: point.yCoord, }); - const flipLeft = point.xCoord > (containerRect?.width ?? 0) / 2; // Fixed elements resolve against the viewport minus scrollbars, not window.inner*. const { clientWidth: viewportWidth, clientHeight: viewportHeight } = document.documentElement; + // Open away from the nearer edges so the tooltip fits without knowing its size. + const flipLeft = point.xCoord > (containerRect?.width ?? 0) / 2; + const flipUp = anchor.y > viewportHeight / 2; return ( <> @@ -77,10 +76,9 @@ export function HeatmapTooltip({ style={{ position: 'fixed', zIndex, - top: Math.max( - 0, - Math.min(anchor.y + 5, viewportHeight - TOOLTIP_MAX_HEIGHT_PX), - ), + ...(flipUp + ? { bottom: viewportHeight - anchor.y + 5 } + : { top: anchor.y + 5 }), ...(flipLeft ? { right: viewportWidth - anchor.x + 10 } : { left: anchor.x + 10 }), diff --git a/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapTooltip.test.tsx b/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapTooltip.test.tsx index 166fb6945b..f75f61b945 100644 --- a/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapTooltip.test.tsx +++ b/packages/app/src/components/DBHeatmapChart/__tests__/HeatmapTooltip.test.tsx @@ -120,8 +120,9 @@ describe('HeatmapTooltip', () => { const tooltip = screen .getByText('time:1000') .closest('div[style*="position: fixed"]'); - // Cursor at y=390 in the viewport: the 150px tooltip is lifted to fit. - expect(tooltip?.style.top).toBe('250px'); + // Cursor at y=390 in a 400px viewport: opens above the cursor. + expect(tooltip?.style.bottom).toBe('15px'); + expect(tooltip?.style.top).toBe(''); // Right half of the container: anchored to the left of the cursor. expect(tooltip?.style.right).toBe('810px'); expect(tooltip?.style.left).toBe(''); From 2ffe44fd83099f7780833cd608f684d9cc7ff3ee Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Wed, 11 Nov 2026 12:17:07 +1100 Subject: [PATCH 5/5] test(app): find the heatmap tooltip in the page body in e2e tests The tooltip now renders into the body, so the page object can't find it inside the chart container anymore. Look it up by test id instead. --- .../src/components/DBHeatmapChart/HeatmapTooltip.tsx | 1 + packages/app/tests/e2e/components/HeatmapComponent.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx b/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx index 0baf53cedd..30974df96d 100644 --- a/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx +++ b/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx @@ -72,6 +72,7 @@ export function HeatmapTooltip({ /> {createPortal(