Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/release-marker-hover-tooltip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@hyperdx/app': minor
---

Hovering a release marker now lists every release in its cluster with the
service that shipped it, its version, and the time. Colour alone could not
identify a service once a chart had more series than the legend shows, and a
collapsed "N releases" cluster named none of them.
35 changes: 35 additions & 0 deletions packages/app/src/HDXMultiSeriesTimeChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
BarChart,
BarProps,
CartesianGrid,
Customized,
Legend,
ReferenceArea,
ReferenceLine,
Expand All @@ -33,9 +34,15 @@ import { Popover } from '@mantine/core';
import type { NumberFormat } from '@/types';
import { COLORS, formatNumber, truncateMiddle } from '@/utils';

import {
AnnotationHitLayer,
type HoveredAnnotation,
} from './components/charts/AnnotationHitLayer';
import { AnnotationTooltip } from './components/charts/AnnotationTooltip';
import {
ChartAnnotation,
getAnnotationElements,
layoutAnnotations,
resolveAnnotationSeries,
} from './components/charts/chartAnnotations';
import { ChartOverlayControls } from './components/charts/ChartOverlayControls';
Expand Down Expand Up @@ -1435,6 +1442,21 @@ export const MemoChart = memo(function MemoChart({
);
}, [annotations, lineData]);

// Same geometry the hit layer positions against, so the hover bands can't
// drift from the lines they belong to.
const laidOutAnnotations = useMemo(() => {
if (!coloredAnnotations?.length) {
return null;
}
return layoutAnnotations(coloredAnnotations, {
domain: xAxisDomain,
plotWidth: Math.max(0, containerWidth - Y_AXIS_WIDTH),
});
}, [coloredAnnotations, xAxisDomain, containerWidth]);

const [hoveredAnnotation, setHoveredAnnotation] =
useState<HoveredAnnotation | null>(null);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

const annotationElements = useMemo(() => {
if (!coloredAnnotations?.length) {
return null;
Expand Down Expand Up @@ -1469,6 +1491,9 @@ export const MemoChart = memo(function MemoChart({
style={{ position: 'relative', width: '100%', height: '100%' }}
>
{nearestSeriesStyle}
{hoveredAnnotation != null && (
<AnnotationTooltip hovered={hoveredAnnotation} />
)}
<ChartOverlayControls
onClearSelection={
onClearSeriesSelection != null &&
Expand Down Expand Up @@ -1573,6 +1598,16 @@ export const MemoChart = memo(function MemoChart({
)}
{referenceLines}
{annotationElements}
{laidOutAnnotations != null && (
<Customized
component={
<AnnotationHitLayer
annotations={laidOutAnnotations}
onHover={setHoveredAnnotation}
/>
}
/>
)}
{highlightStart && highlightEnd ? (
<ReferenceArea
// yAxisId="1"
Expand Down
104 changes: 104 additions & 0 deletions packages/app/src/components/charts/AnnotationHitLayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
DefaultZIndexes,
usePlotArea,
useXAxisScale,
ZIndexLayer,
} from 'recharts';

import type { PositionedAnnotation } from './chartAnnotations';

/**
* Minimum width of a marker's hover target. A dashed 1px line is unhittable, so
* each cluster gets a band at least this wide centred on it.
*/
const MIN_HIT_WIDTH_PX = 14;

export type HoveredAnnotation = {
/** The cluster's labelled anchor. `members` names everything inside it. */
annotation: PositionedAnnotation;
/** Pointer position in chart-container pixels. */
point: { x: number; y: number };
};

/**
* Transparent hover targets over the annotation markers, so a marker can name
* the service it belongs to rather than relying on its colour — which stops
* being resolvable once the legend overflows past `MAX_LEGEND_ITEMS`.
*
* Rendered inside the chart through Recharts' `<Customized>` so it can read the
* real plot geometry (`usePlotArea`, `useXAxisScale`) instead of re-deriving it
* from the container width. Handlers deliberately do not stop propagation:
* events still bubble to the chart wrapper, which is what keeps drag-to-zoom
* and the series tooltip working underneath the bands.
*/
export function AnnotationHitLayer({
annotations,
onHover,
}: {
annotations: PositionedAnnotation[];
onHover: (hovered: HoveredAnnotation | null) => void;
}) {
const plot = usePlotArea();
const scale = useXAxisScale();

if (plot == null || scale == null) {
return null;
}

// One target per cluster, not per line. Anchors carry `members`; the muted
// lines inside a cluster are already covered by their anchor's band, and
// giving them their own rects would just have them intercept each other.
const anchors = annotations.filter(annotation => annotation.members != null);

return (
// Above every other chart layer, otherwise the series areas (zIndex 100)
// and the marker lines (400) receive the pointer instead of these bands.
<ZIndexLayer zIndex={DefaultZIndexes.label + 1}>
<g className="recharts-annotation-hit-layer">
{anchors.map((annotation, i) => {
const members = annotation.members ?? [annotation];
// Span the whole cluster so hovering any of its lines — including the
// muted ones, which carry no label — surfaces the same tooltip.
const xs = members
.map(member => scale(member.x))
.filter((px): px is number => px != null && Number.isFinite(px));
if (xs.length === 0) {
return null;
}
const left = Math.min(...xs);
const right = Math.max(...xs);

const width = Math.max(right - left, MIN_HIT_WIDTH_PX);
const x = left - (width - (right - left)) / 2;

return (
<rect
key={annotation.key ?? `annotation-hit-${annotation.x}-${i}`}
x={x}
// Confined to the label headroom above the plot. Covering the plot
// too would make the series tooltip fire alongside this one, and
// the label is the natural thing to aim at anyway.
y={0}
width={width}
height={plot.y}
fill="transparent"
pointerEvents="all"
style={{ cursor: 'default' }}
onMouseEnter={event => {
// Measured here rather than passed down from the chart
// container: reading the DOM in an event handler is fine,
// whereas reading a ref during render is not.
const band = event.currentTarget.getBoundingClientRect();
onHover({
annotation,
point: { x: band.left + band.width / 2, y: band.bottom },
});
}}
onMouseLeave={() => onHover(null)}
/>
);
})}
</g>
</ZIndexLayer>
);
}
80 changes: 80 additions & 0 deletions packages/app/src/components/charts/AnnotationTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createPortal } from 'react-dom';
import { Group, Stack, Text } from '@mantine/core';

import type { HoveredAnnotation } from './AnnotationHitLayer';
import {
ChartTooltipContainer,
ChartTooltipHeader,
useChartTooltipZIndex,
} from './ChartTooltip';

/** Gap below the marker so the tooltip does not sit on top of its own label. */
const TOOLTIP_OFFSET_PX = 6;

/**
* Hover tooltip for a release marker: one row per release in the hovered
* cluster, each naming the service it belongs to.
*
* The service name is the whole point. On the chart the only carrier of that is
* the marker's colour, which stops being resolvable once the legend overflows
* past `MAX_LEGEND_ITEMS` and the matching entry hides behind "+N more".
*
* Portaled to the body and positioned fixed, matching the series tooltip: a
* dashboard tile clips its overflow, so an absolutely-positioned tooltip gets
* cut off at the tile edge. `hovered.point` is already in viewport pixels,
* measured from the hit band when the pointer entered it.
*/
export function AnnotationTooltip({ hovered }: { hovered: HoveredAnnotation }) {
const zIndex = useChartTooltipZIndex();
const members = hovered.annotation.members ?? [hovered.annotation];

if (typeof document === 'undefined') {
return null;
}

return createPortal(
<div
style={{
position: 'fixed',
left: hovered.point.x,
top: hovered.point.y + TOOLTIP_OFFSET_PX,
transform: 'translateX(-50%)',
pointerEvents: 'none',
zIndex,
}}
>
<ChartTooltipContainer
header={<ChartTooltipHeader labelSeconds={hovered.annotation.x} />}
>
<Stack gap={4}>
{members.map((member, i) => (
<Group
key={member.key ?? `annotation-row-${member.x}-${i}`}
gap={8}
wrap="nowrap"
>
<div
style={{
width: 8,
height: 8,
borderRadius: 2,
flexShrink: 0,
background: member.color ?? 'var(--color-border)',
}}
/>
{member.group != null && (
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>
{member.group}
</Text>
)}
<Text size="xs" fw={500} style={{ whiteSpace: 'nowrap' }}>
{member.label}
</Text>
</Group>
))}
</Stack>
</ChartTooltipContainer>
</div>,
document.body,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type ChartAnnotation,
getAnnotationElements,
labelSeparationPx,
layoutAnnotations,
MAX_ANNOTATION_MARKERS,
mergeAnnotations,
resolveAnnotationSeries,
Expand Down Expand Up @@ -428,3 +429,62 @@ describe('resolveAnnotationSeries', () => {
expect(input.color).toBe('#fallback');
});
});

describe('layoutAnnotations', () => {
const collapsing = {
domain: [0, 1000] as [number, number],
plotWidth: 1000,
};
const release = (seconds: number, label: string, group: string) => ({
time: seconds * 1000,
label,
group,
kind: 'release',
groupNoun: 'releases',
});

// The hover tooltip reads `members` so a cluster shown as "N releases" can
// still name every service inside it — which colour alone cannot do once the
// legend overflows.
it('gives a collapsed cluster the members it swallowed', () => {
const laidOut = layoutAnnotations(
[
release(0, '1.0.0', 'checkout'),
release(10, '2.0.0', 'payments'),
release(600, '3.0.0', 'search'),
],
collapsing,
);

const anchors = laidOut.filter(a => a.members != null);
expect(anchors).toHaveLength(2);
expect(anchors[0].label).toBe('2 releases');
expect(anchors[0].members?.map(m => m.group)).toEqual([
'checkout',
'payments',
]);
// Members keep their own labels, so the tooltip shows real versions.
expect(anchors[0].members?.map(m => m.label)).toEqual(['1.0.0', '2.0.0']);
expect(anchors[1].members?.map(m => m.group)).toEqual(['search']);
});

it('gives a lone marker itself as its only member', () => {
const [only] = layoutAnnotations(
[release(500, '1.0.0', 'checkout')],
collapsing,
);

expect(only.members?.map(m => m.label)).toEqual(['1.0.0']);
});

// Before the plot is measured there is no collapsing, but hover still needs
// members to render a row.
it('populates members even when the geometry is unknown', () => {
const laidOut = layoutAnnotations(
[release(0, '1.0.0', 'checkout'), release(10, '2.0.0', 'payments')],
{ domain: collapsing.domain },
);

expect(laidOut.every(a => a.members?.length === 1)).toBe(true);
});
});
Loading
Loading