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..85d20a805a 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'; @@ -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); @@ -109,6 +119,8 @@ export function HeatmapPlot({ }); const { ref, width, height } = useElementSize(); + 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 @@ -329,7 +341,7 @@ export function HeatmapPlot({ return (
{ @@ -374,8 +386,7 @@ export function HeatmapPlot({ )} diff --git a/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx b/packages/app/src/components/DBHeatmapChart/HeatmapTooltip.tsx index fa0abe1761..30974df96d 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,29 @@ 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; }; export function HeatmapTooltip({ point, cell, - width, - height, + containerRect, showDragHint, }: HeatmapTooltipProps) { + const zIndex = useChartTooltipZIndex(); + const anchor = toViewportPoint(containerRect, { + x: point.xCoord, + y: point.yCoord, + }); + // 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 ( <>
-
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__/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 28dbe42f9a..f75f61b945 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,43 @@ 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(); + }); + + 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 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(''); + viewport.forEach(spy => spy.mockRestore()); + }); }); diff --git a/packages/app/tests/e2e/components/HeatmapComponent.ts b/packages/app/tests/e2e/components/HeatmapComponent.ts index 4bbb267842..5d377ca363 100644 --- a/packages/app/tests/e2e/components/HeatmapComponent.ts +++ b/packages/app/tests/e2e/components/HeatmapComponent.ts @@ -29,12 +29,17 @@ export class HeatmapComponent { return this.root.getByText('Not enough data points to render heatmap'); } + /** The open cell tooltip, rendered into the page body rather than the chart. */ + private get tooltipRoot() { + return this.page.getByTestId('heatmap-tooltip'); + } + /** * The hover tooltip, which only appears for a cell with a non-zero count. * Series-axis heatmaps label the cell's series instead of its count. */ get tooltip() { - return this.container.getByText(/^(Count Value|Series):$/); + return this.tooltipRoot.getByText(/^(Count Value|Series):$/); } /** @@ -70,7 +75,7 @@ export class HeatmapComponent { /** The series named by the open cell tooltip of a series-axis heatmap. */ async hoveredSeriesName() { - const row = this.container + const row = this.tooltipRoot .getByText('Series:', { exact: true }) .locator('..'); await expect(row).toBeVisible(); @@ -79,7 +84,7 @@ export class HeatmapComponent { /** A labelled value of the open cell tooltip, e.g. `Count Value`. */ async hoveredTooltipValue(label: 'Count Value' | 'Y Value' | 'Bucket') { - const row = this.container + const row = this.tooltipRoot .getByText(`${label}:`, { exact: true }) .locator('..'); await expect(row).toBeVisible();