From a1b33505f0b6730d2450999db847a664739d2838 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Wed, 5 Aug 2026 16:53:16 +1000 Subject: [PATCH 1/4] refactor(app): split the two largest chart files into directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HDXMultiSeriesTimeChart.tsx 1503 -> 720 (+ 8 files) DBTimeChart.tsx 1022 -> 469 (+ 6 files) Each becomes a directory with a barrel, so every import path and every jest.mock('@/...') keeps resolving and the change is invisible to consumers. The seams follow what the code already separated rather than cutting by length: - searchUrl.ts takes buildSearchUrl out of a useCallback as a pure function, which makes its branching testable for the first time — which value column a series key resolves to, and whether that column's aggregation is attributable to individual events at all. A non-attributable aggregation must not produce a value filter, or drill-down returns rows that never contributed to the clicked point. Nine new tests cover it; that is the only new test surface here. - useChartScales holds the axis domains and the annotation elements. Pure derivation from props, no state, no recharts tree. - The tooltip, legend, recharts shape shims, layout constants, cross-chart pin registry and pure data helpers each move to a file named after what they are. MemoChart stays at 720 because what is left is one recharts element whose children must remain siblings, plus interaction state that genuinely shares a click-suppression flag with the brush-zoom. Splitting that tree would trade a real risk of dropping a render branch for a smaller number. Two deliberate changes beyond pure movement, both small: - xAxisDomain is typed as the [number, number] tuple it already returned rather than the wider AxisDomain, which removes two unsafe type assertions at its consumers. - dismissPinned and buildSearchUrl became block-bodied and a thin delegate respectively. Same behaviour. Verified by diffing every non-import line of the two originals against the new directories: the only lines that do not appear are import fragments that were redistributed and the two changes above. make ci-lint, make ci-unit (5205 tests) and the dashboard E2E suite (81 passed) all pass. E2E matters most here — it is the only check that catches a runtime-only breakage from moving code. First of the pieces split out of #2536. No feature code: this lands ahead of the exemplars overlay so that PR is a feature diff rather than a feature plus a 1,500-line move. --- .../DBTimeChart/__tests__/searchUrl.test.ts | 179 ++ packages/app/src/HDXMultiSeriesTimeChart.tsx | 1730 ----------------- .../HDXMultiSeriesTimeChart/ChartLegend.tsx | 174 ++ .../ChartTooltipContent.tsx | 182 ++ .../src/HDXMultiSeriesTimeChart/MemoChart.tsx | 872 +++++++++ .../HDXMultiSeriesTimeChart/TooltipItem.tsx | 46 + .../__tests__/HDXMultiSeriesTimeChart.test.ts | 0 .../src/HDXMultiSeriesTimeChart/chartData.ts | 297 +++ .../chartPrimitives.tsx | 75 + .../src/HDXMultiSeriesTimeChart/constants.ts | 25 + .../app/src/HDXMultiSeriesTimeChart/index.ts | 20 + .../HDXMultiSeriesTimeChart/useChartScales.ts | 184 ++ .../DBTimeChart/ChartTooltipOverlay.tsx | 164 ++ .../{ => DBTimeChart}/DBTimeChart.tsx | 519 +---- .../__tests__/DBTimeChart.test.tsx | 4 +- .../DBTimeChart/__tests__/searchUrl.test.ts | 179 ++ .../components/DBTimeChart/crossChartPin.ts | 34 + .../app/src/components/DBTimeChart/index.ts | 7 + .../src/components/DBTimeChart/searchUrl.ts | 208 ++ .../DBTimeChart/useChartToolbarItems.tsx | 171 ++ 20 files changed, 2856 insertions(+), 2214 deletions(-) create mode 100644 packages/app/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts delete mode 100644 packages/app/src/HDXMultiSeriesTimeChart.tsx create mode 100644 packages/app/src/HDXMultiSeriesTimeChart/ChartLegend.tsx create mode 100644 packages/app/src/HDXMultiSeriesTimeChart/ChartTooltipContent.tsx create mode 100644 packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx create mode 100644 packages/app/src/HDXMultiSeriesTimeChart/TooltipItem.tsx rename packages/app/src/{ => HDXMultiSeriesTimeChart}/__tests__/HDXMultiSeriesTimeChart.test.ts (100%) create mode 100644 packages/app/src/HDXMultiSeriesTimeChart/chartData.ts create mode 100644 packages/app/src/HDXMultiSeriesTimeChart/chartPrimitives.tsx create mode 100644 packages/app/src/HDXMultiSeriesTimeChart/constants.ts create mode 100644 packages/app/src/HDXMultiSeriesTimeChart/index.ts create mode 100644 packages/app/src/HDXMultiSeriesTimeChart/useChartScales.ts create mode 100644 packages/app/src/components/DBTimeChart/ChartTooltipOverlay.tsx rename packages/app/src/components/{ => DBTimeChart}/DBTimeChart.tsx (55%) rename packages/app/src/components/{ => DBTimeChart}/__tests__/DBTimeChart.test.tsx (99%) create mode 100644 packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts create mode 100644 packages/app/src/components/DBTimeChart/crossChartPin.ts create mode 100644 packages/app/src/components/DBTimeChart/index.ts create mode 100644 packages/app/src/components/DBTimeChart/searchUrl.ts create mode 100644 packages/app/src/components/DBTimeChart/useChartToolbarItems.tsx diff --git a/packages/app/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts b/packages/app/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts new file mode 100644 index 0000000000..57f2e17e62 --- /dev/null +++ b/packages/app/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts @@ -0,0 +1,179 @@ +import { + type ChartConfigWithDateRange, + SourceKind, + TSource, +} from '@hyperdx/common-utils/dist/types'; + +import { buildSeriesSearchUrl } from '@/components/DBTimeChart/searchUrl'; + +// The URL string itself is ChartUtils' concern and already covered there. What +// matters here is the branching this function does before delegating — which was +// previously locked inside a useCallback and untestable. +type SearchUrlArgs = { + dateRange: [Date, Date]; + groupFilters: { column: string; value: string }[]; + valueRangeFilter?: { expression: string; value: number }; +}; + +const mockBuildEventsSearchUrl = jest.fn( + () => '/search?mocked=1', +); +jest.mock('@/ChartUtils', () => ({ + ...jest.requireActual('@/ChartUtils'), + buildEventsSearchUrl: (arg: SearchUrlArgs) => mockBuildEventsSearchUrl(arg), +})); + +/** Args the function handed to buildEventsSearchUrl on its first call. */ +const delegatedArgs = () => mockBuildEventsSearchUrl.mock.calls[0][0]; + +const source = { + id: 'src-1', + kind: SourceKind.Log, + name: 'logs', + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'otel_logs' }, + timestampValueExpression: 'Timestamp', +} as unknown as TSource; + +const clicked = new Date('2026-01-01T00:00:00Z'); + +const args = { + clickedActiveLabelDate: clicked as Date | undefined, + source: source as TSource | undefined, + granularity: '1 minute', + groupColumns: [] as string[], + valueColumns: undefined as string[] | undefined, + isSingleValueColumn: true as boolean | undefined, +}; + +const configWith = ( + select: { aggFn: string; valueExpression: string }[], +): ChartConfigWithDateRange => + ({ + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'otel_logs' }, + timestampValueExpression: 'Timestamp', + where: '', + select, + dateRange: [clicked, clicked], + }) as ChartConfigWithDateRange; + +const rawSqlConfig = { + connection: 'conn-1', + configType: 'sql', + sqlTemplate: 'SELECT 1', + dateRange: [clicked, clicked], +} satisfies Partial as ChartConfigWithDateRange; + +beforeEach(() => jest.clearAllMocks()); + +describe('buildSeriesSearchUrl', () => { + describe('returns null when drill-down cannot be supported', () => { + it('with no clicked date', () => { + expect( + buildSeriesSearchUrl({ + ...args, + clickedActiveLabelDate: undefined, + config: configWith([{ aggFn: 'avg', valueExpression: 'Duration' }]), + }), + ).toBeNull(); + expect(mockBuildEventsSearchUrl).not.toHaveBeenCalled(); + }); + + it('with no resolved source', () => { + expect( + buildSeriesSearchUrl({ + ...args, + source: undefined, + config: configWith([{ aggFn: 'avg', valueExpression: 'Duration' }]), + }), + ).toBeNull(); + }); + + it('for a raw SQL chart', () => { + // Raw SQL doesn't resolve to a single source, so there is nothing to search. + expect( + buildSeriesSearchUrl({ + ...args, + config: rawSqlConfig, + }), + ).toBeNull(); + }); + }); + + it('ranges from the clicked bucket to one granularity later', () => { + buildSeriesSearchUrl({ + ...args, + config: configWith([{ aggFn: 'avg', valueExpression: 'Duration' }]), + }); + const { dateRange } = delegatedArgs(); + expect(dateRange[0]).toEqual(clicked); + expect(dateRange[1].getTime() - clicked.getTime()).toBe(60_000); + }); + + describe('value-range filter', () => { + it('is added for an attributable aggregation', () => { + buildSeriesSearchUrl({ + ...args, + seriesValue: 250, + config: configWith([{ aggFn: 'max', valueExpression: 'Duration' }]), + }); + const { valueRangeFilter } = delegatedArgs(); + expect(valueRangeFilter).toEqual({ + expression: 'Duration', + value: 250, + }); + }); + + it('is omitted for a non-attributable aggregation', () => { + // A `count`/`sum` point is not attributable to any single event's value, so + // filtering on it would return rows that never contributed to the point. + buildSeriesSearchUrl({ + ...args, + seriesValue: 250, + config: configWith([{ aggFn: 'count', valueExpression: 'Duration' }]), + }); + const { valueRangeFilter } = delegatedArgs(); + expect(valueRangeFilter).toBeUndefined(); + }); + + it('is omitted when no series value was clicked', () => { + buildSeriesSearchUrl({ + ...args, + config: configWith([{ aggFn: 'max', valueExpression: 'Duration' }]), + }); + const { valueRangeFilter } = delegatedArgs(); + expect(valueRangeFilter).toBeUndefined(); + }); + + it('resolves the value column by series-key prefix on a multi-value chart', () => { + // With more than one value column the series key is prefixed with the + // column name, so the filter must follow that prefix to the right select + // item rather than defaulting to select[0]. + buildSeriesSearchUrl({ + ...args, + seriesKey: 'p95', + seriesValue: 900, + isSingleValueColumn: false, + valueColumns: ['count', 'p95'], + config: configWith([ + { aggFn: 'count', valueExpression: 'Body' }, + { aggFn: 'p95', valueExpression: 'Duration' }, + ]), + }); + const { valueRangeFilter } = delegatedArgs(); + expect(valueRangeFilter).toEqual({ expression: 'Duration', value: 900 }); + }); + }); + + it('passes decoded group filters through', () => { + buildSeriesSearchUrl({ + ...args, + seriesKey: 'api', + groupColumns: ['ServiceName'], + config: configWith([{ aggFn: 'avg', valueExpression: 'Duration' }]), + }); + const { groupFilters } = delegatedArgs(); + expect(groupFilters).toEqual([{ column: 'ServiceName', value: 'api' }]); + }); +}); diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx deleted file mode 100644 index 1cab711763..0000000000 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ /dev/null @@ -1,1730 +0,0 @@ -import { - memo, - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from 'react'; -import cx from 'classnames'; -import { add, isSameSecond, sub } from 'date-fns'; -import { withErrorBoundary } from 'react-error-boundary'; -import { - Area, - AreaChart, - Bar, - BarChart, - BarProps, - CartesianGrid, - Customized, - Legend, - ReferenceArea, - ReferenceLine, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; -import { AxisDomain } from 'recharts/types/util/types'; -import { convertGranularityToSeconds } from '@hyperdx/common-utils/dist/core/utils'; -import { DisplayType } from '@hyperdx/common-utils/dist/types'; -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'; -import { - ChartTooltipContainer, - ChartTooltipHeader, - ChartTooltipItem, - toViewportPoint, - useChartTooltipZIndex, -} from './components/charts/ChartTooltip'; -import { useChartSyncId } from './chartSync'; -import { - findNearestSeriesKey, - getSeriesColorForGroup, - LineData, - MAX_TIME_CHART_SERIES, - toStartOfInterval, -} from './ChartUtils'; -import { useFormatTime } from './useFormatTime'; - -import styles from '@styles/HDXLineChart.module.scss'; - -const MAX_LEGEND_ITEMS = 4; - -// Max rows rendered in a series tooltip (hover and pinned). Each row mounts a -// DOM node (the pinned one also a Mantine Tooltip), so an uncapped busy bucket -// was a jank source; the rest collapse into a "+N more" line (see -// getVisibleTooltipRows). Exported so the pinned tooltip shares the cap. -export const MAX_TOOLTIP_ROWS = 20; - -// Vertical pixel distance within which a series' line counts as "near" the -// cursor for tooltip highlighting. Beyond this, no row is emphasized so the -// tooltip is not misleading when the pointer is in empty space. -const NEAREST_SERIES_MAX_DISTANCE_PX = 30; - -// Gap below the data point for the hover tooltip. Kept equal to the pinned -// tooltip's Popover `offset` so both land in the same spot. -const TOOLTIP_POINT_OFFSET_PX = 12; - -const Y_AXIS_WIDTH = 40; -const SINGLE_POINT_BAR_RIGHT_PADDING = 10; -const SINGLE_POINT_BAR_WIDTH_RATIO = 0.8; -// Top margin (px) reserved above the plot for annotation labels ("Alert"/"OK"), -// added only when a chart is showing annotations so other charts keep their -// tighter default headroom. -const ANNOTATION_LABEL_HEADROOM = 18; - -type TooltipPayload = { - dataKey: string; - name: string; - value: number; - color?: string; - stroke?: string; - strokeWidth?: number; - strokeDasharray?: string; - opacity?: number; -}; - -export const TooltipItem = memo( - ({ - p, - previous, - numberFormat, - highlighted, - dimmed, - }: { - p: TooltipPayload; - previous?: TooltipPayload; - numberFormat?: NumberFormat; - highlighted?: boolean; - dimmed?: boolean; - }) => { - return ( - - ); - }, -); - -type HDXLineChartTooltipProps = { - lineDataMap: { [keyName: string]: LineData }; - previousPeriodOffsetSeconds?: number; - numberFormat?: NumberFormat; - numberFormatByKey: Map; - /** Per-series active-point pixel Y, captured by the Area active dots. */ - activePointYByKeyRef: React.MutableRefObject>; - /** The chart's outer container; its viewport rect anchors this tooltip. */ - containerRef: React.MutableRefObject; -} & Record; - -/** - * The recharts `` content used for the HOVER tooltip (on the hovered - * chart and its synced followers). Clicking pins ChartSeriesTooltip instead. - * - * Because it's given `portal={document.body}`, recharts skips its own transform - * positioning, so this content self-anchors at the active point with - * `position: fixed` (container rect + `coordinate`) — matching the pinned - * tooltip's anchor, and escaping the chart's bounds so edges aren't clipped. - */ -const HDXLineChartTooltip = withErrorBoundary( - memo((props: HDXLineChartTooltipProps) => { - const { - active, - payload, - label, - numberFormat, - numberFormatByKey, - lineDataMap, - previousPeriodOffsetSeconds, - activePointYByKeyRef, - containerRef, - } = props; - const typedPayload = payload as TooltipPayload[]; - - const tooltipZIndex = useChartTooltipZIndex(); - - const payloadByKey = useMemo( - () => new Map(typedPayload.map(p => [p.dataKey, p])), - [typedPayload], - ); - - if (active && payload && payload.length) { - // No onClose: hover renders the X hidden (kept for layout parity). - const header = ( - - ); - - // Bold the line nearest the cursor by comparing pointer Y to each series' - // active-dot Y. The dots write their positions earlier in this same render - // (Recharts draws graphical items before the tooltip), so it's current. - const pointerY: number | undefined = props.coordinate?.y; - // eslint-disable-next-line react-hooks/refs - const activePointYByKey = activePointYByKeyRef?.current ?? undefined; - const nearestSeriesKey = - typedPayload.length > 1 - ? findNearestSeriesKey( - activePointYByKey, - typedPayload.map(p => p.dataKey), - pointerY, - NEAREST_SERIES_MAX_DISTANCE_PX, - ) - : undefined; - - // Anchor at the active point (see the component docblock for why fixed). - const pointX = props.coordinate?.x; - const pointY = props.coordinate?.y; - // eslint-disable-next-line react-hooks/refs - const containerRect = containerRef?.current?.getBoundingClientRect(); - const anchor = - typeof pointX === 'number' && - typeof pointY === 'number' && - containerRect != null - ? toViewportPoint(containerRect, { x: pointX, y: pointY }) - : undefined; - const anchorStyle: React.CSSProperties = - anchor != null - ? { - position: 'fixed', - left: anchor.x, - top: anchor.y + TOOLTIP_POINT_OFFSET_PX, - transform: 'translateX(-50%)', - pointerEvents: 'none', - // z-index must live here: recharts leaves the portaled wrapper - // `position: static`, where z-index has no effect. - zIndex: tooltipZIndex, - } - : {}; - - // Copy before sorting: Recharts 3 freezes the payload, so an in-place - // sort throws "this object has been frozen". - const sortedPayload = [...typedPayload].sort( - (a: TooltipPayload, b: TooltipPayload) => b.value - a.value, - ); - - // Cap how many rows are rendered per frame (see getVisibleTooltipRows). - const { rows: visiblePayload, hiddenCount: hiddenRowCount } = - getVisibleTooltipRows( - sortedPayload, - nearestSeriesKey, - MAX_TOOLTIP_ROWS, - ); - - return ( -
- - {visiblePayload.map((p: TooltipPayload) => { - const previousKey = lineDataMap[p.dataKey]?.previousPeriodKey; - const isPreviousPeriod = previousKey === p.dataKey; - const previousPayload = - !isPreviousPeriod && previousKey - ? payloadByKey.get(previousKey) - : undefined; - const valueColumnName = - lineDataMap[p.dataKey]?.valueColumnName ?? p.dataKey; - const numberFormatForKey = - numberFormatByKey.get(valueColumnName) ?? numberFormat; - - return ( - - ); - })} - {hiddenRowCount > 0 && ( -
- +{hiddenRowCount.toLocaleString()} more -
- )} -
-
- ); - } - return null; - }), - { - onError: console.error, - fallback: ( -
- An error occurred while rendering the tooltip. -
- ), - }, -); - -function ExpandableLegendItem({ - entry, - expanded, - isSelected, - isDisabled, - onToggle, -}: { - entry: any; - expanded?: boolean; - isSelected?: boolean; - isDisabled?: boolean; - onToggle?: (isShiftKey: boolean) => void; -}) { - const [_expanded, setExpanded] = useState(false); - const isExpanded = _expanded || expanded; - - return ( - { - if (onToggle) { - onToggle(e.shiftKey); - } else { - setExpanded(v => !v); - } - }} - title={ - isSelected - ? 'Click to show all (Shift+click to deselect)' - : 'Click to show only this (Shift+click for multi-select)' - } - > -
- - - -
- {isExpanded || isSelected - ? entry.value - : truncateMiddle(`${entry.value}`, 35)} -
- ); -} - -const LegendRenderer = memo<{ - payload?: { - dataKey: string; - value: string; - color: string; - }[]; - lineDataMap: { [key: string]: LineData }; - allLineData?: LineData[]; - selectedSeries?: Set; - onToggleSeries?: (seriesName: string, isShiftKey?: boolean) => void; -}>(props => { - const { payload, lineDataMap, allLineData, selectedSeries, onToggleSeries } = - props; - - const hasSelection = hasSeriesSelection(selectedSeries); - - // Use allLineData to ensure all series are always shown in legend - const allSeriesPayload = useMemo(() => { - if (allLineData?.length) { - return allLineData.map(ld => ({ - dataKey: ld.dataKey, - value: ld.displayName || ld.dataKey, - color: ld.color, - payload: { strokeDasharray: ld.isDashed ? '4 3' : '0' }, - })); - } - return payload ?? []; - }, [allLineData, payload]); - - const sortedLegendItems = useMemo(() => { - // Order items such that current and previous period lines are consecutive - const currentPeriodKeyIndex = new Map(); - allSeriesPayload.forEach((line, index) => { - const currentPeriodKey = - lineDataMap[line.dataKey]?.currentPeriodKey || ''; - if (!currentPeriodKeyIndex.has(currentPeriodKey)) { - currentPeriodKeyIndex.set(currentPeriodKey, index); - } - }); - - // Copy before sorting: when this comes from Recharts' legend payload it is - // kept in the Immer-backed store and frozen, so an in-place sort throws. - return [...allSeriesPayload].sort((a, b) => { - const keyA = lineDataMap[a.dataKey]?.currentPeriodKey ?? ''; - const keyB = lineDataMap[b.dataKey]?.currentPeriodKey ?? ''; - - const indexA = currentPeriodKeyIndex.get(keyA) ?? 0; - const indexB = currentPeriodKeyIndex.get(keyB) ?? 0; - - return indexB - indexA || a.dataKey.localeCompare(b.dataKey); - }); - }, [allSeriesPayload, lineDataMap]); - - const shownItems = sortedLegendItems.slice(0, MAX_LEGEND_ITEMS); - const restItems = sortedLegendItems.slice(MAX_LEGEND_ITEMS); - - return ( -
- {shownItems.map((entry, index) => { - const isSelected = !!selectedSeries?.has(entry.value); - const isDisabled = hasSelection && !isSelected; - return ( - onToggleSeries?.(entry.value, isShiftKey)} - /> - ); - })} - {restItems.length ? ( - - -
- +{restItems.length} more -
-
- -
- {restItems.map((entry, index) => { - const isSelected = !!selectedSeries?.has(entry.value); - const isDisabled = hasSelection && !isSelected; - return ( - - onToggleSeries?.(entry.value, isShiftKey) - } - /> - ); - })} -
-
-
- ) : null} -
- ); -}); - -export const HARD_LINES_LIMIT = MAX_TIME_CHART_SERIES; - -// Debounce (ms) for the chart's ResponsiveContainer resize observer. Without -// it the observer fires on every frame, and a resize → re-render → resize -// cycle can keep the chart (and the form controls around it in the tile -// editor) from ever settling. -const RESPONSIVE_CONTAINER_DEBOUNCE_MS = 50; - -/** One series entry in a tooltip's per-bucket payload (hover or click-frozen). */ -export type ActiveClickSeries = { - value?: number; - dataKey?: string; - name?: string; - /** Series color, matching the legend swatch. */ - color?: string; - /** Previous-period value at the same bucket, for the percent-change chip. */ - previousValue?: number; - /** Whether this series is a dashed previous-period line. */ - isPreviousPeriod?: boolean; - /** Result column the values came from, for per-column number formatting. */ - valueColumnName?: string; -}; - -/** - * State for the pinned (click-locked) tooltip. Produced by MemoChart's onClick - * and rendered by DBTimeChart via ChartSeriesTooltip. (Hover uses recharts' own - * ; recharts' is also kept for its synced cursor.) - */ -export type ActiveClickPayload = { - /** Active point in viewport coords; the Popover anchor. */ - viewportX: number; - viewportY: number; - activeLabel: string; - activePayload?: ActiveClickSeries[]; -}; - -/** Series label shown in the legend, tooltip, and line `name`. */ -const getSeriesDisplayName = (ld: LineData) => ld.displayName || ld.dataKey; - -/** - * Stable, CSS-safe class for a series' , unique per chart (`id`) and - * series (`dataKey`). Lets the nearest-cursor emphasis target one line via CSS - * without changing any prop (which would rebuild every line on hover). - */ -const seriesClassName = (id: string, dataKey: string) => - `hdx-series-${id}-${dataKey.replace(/[^a-zA-Z0-9_-]/g, '_')}`; - -// The subset of recharts' loosely-typed chart mouse-event `state` we read. -type ChartMouseState = { - activeLabel?: string | number; - activeCoordinate?: { x?: number; y?: number }; -}; - -/** Normalize a chart event's active label (number | string) to a string. */ -const getActiveLabel = (state?: { - activeLabel?: string | number; -}): string | undefined => - state?.activeLabel != null ? String(state.activeLabel) : undefined; - -/** - * Build the per-series payload for a click-frozen tooltip from the data row at - * the clicked bucket. Only the visible series (legend selection + - * HARD_LINES_LIMIT) with a numeric value at that bucket are included, so the - * drill-down popover mirrors exactly what is drawn. Exported for unit testing. - */ -export function buildActiveClickSeries( - visibleLineData: LineData[], - activeRow: Record | undefined, -): ActiveClickSeries[] { - if (activeRow == null) return []; - return visibleLineData.flatMap(ld => { - const value = activeRow[ld.dataKey]; - // Exclude non-finite values (NaN/±Infinity) — e.g. a ratio chart's - // zero-denominator bucket yields NaN. The tooltip already drops these - // (ChartSeriesTooltip filters on Number.isFinite), and admitting them here - // would also break the sameActiveClickSeries equality guard (NaN !== NaN). - if (typeof value !== 'number' || !Number.isFinite(value)) return []; - const isPreviousPeriod = ld.previousPeriodKey === ld.dataKey; - // Pair each current-period series with its previous-period value for the - // percent-change chip. Only current-period rows carry a comparison. - const previousRaw = - !isPreviousPeriod && ld.previousPeriodKey - ? activeRow[ld.previousPeriodKey] - : undefined; - return [ - { - dataKey: ld.dataKey, - name: getSeriesDisplayName(ld), - value, - color: ld.color, - isPreviousPeriod, - valueColumnName: ld.valueColumnName, - previousValue: - typeof previousRaw === 'number' ? previousRaw : undefined, - }, - ]; - }); -} - -/** - * Shallow structural equality for two click-frozen payloads, used to decide - * whether an open pin's snapshot needs rebuilding. Compares the drawn set - * (length + per-row dataKey) and the value/previousValue at the pinned bucket; - * a change in any means the tooltip's rows or its "+N more" overflow would - * differ. Cheap and order-sensitive — `buildActiveClickSeries` derives both - * sides from the same `tooltipLineData` ordering, so positions stay aligned. - * - * Uses `Object.is` for the numeric fields so a `NaN` value compares equal to - * itself (a plain `!==` would report NaN-holding snapshots as perpetually - * changed and drive the resync effect into an infinite update loop). - */ -export function sameActiveClickSeries( - a: ActiveClickSeries[] | undefined, - b: ActiveClickSeries[], -): boolean { - if (a == null || a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if ( - a[i].dataKey !== b[i].dataKey || - !Object.is(a[i].value, b[i].value) || - !Object.is(a[i].previousValue, b[i].previousValue) - ) { - return false; - } - } - return true; -} - -/** - * The series actually drawn on the chart. Without a selection, the first - * HARD_LINES_LIMIT of lineData. With a selection (legend isolate, checkbox - * filter, or table search), the selection is applied FIRST and then capped, so - * an explicitly chosen series always draws even if it ranks beyond the limit. - * Applying the cap first would slice out a chosen low-ranked series, leaving an - * empty chart while its stats still show in the legend table. The rendered - * lines and the drill-down click payload both derive from this same set so they - * never diverge. Exported for unit testing. - */ -/** - * Whether a series selection is active. The single source of truth for the - * "isolate to these series" predicate that gates line visibility, the y-axis - * domain, legend dimming, and the "Show All Series" control — so those can't - * drift out of sync. - */ -function hasSeriesSelection( - selectedSeriesNames: Set | undefined, -): selectedSeriesNames is Set { - return !!selectedSeriesNames && selectedSeriesNames.size > 0; -} - -export function getVisibleLineData( - lineData: LineData[], - selectedSeriesNames: Set | undefined, -): LineData[] { - return getSelectedLineData(lineData, selectedSeriesNames).slice( - 0, - HARD_LINES_LIMIT, - ); -} - -/** - * The series that survive the legend/table selection, WITHOUT the - * HARD_LINES_LIMIT draw cap. Same selection semantics as getVisibleLineData - * (selection applied first), so an explicitly chosen series is always kept. - * The pinned tooltip's rows derive from this so the "load all series" escape - * hatch can list series that were materialized but not drawn — the draw cap - * (getVisibleLineData) exists to keep the chart readable/fast, not to bound the - * scrollable drill-down list. Exported for unit testing. - */ -export function getSelectedLineData( - lineData: LineData[], - selectedSeriesNames: Set | undefined, -): LineData[] { - const hasSelection = hasSeriesSelection(selectedSeriesNames); - if (hasSelection) { - return lineData.filter(ld => - selectedSeriesNames.has(getSeriesDisplayName(ld)), - ); - } - return lineData; -} - -/** - * The series-tooltip rows to render (hover or pinned). `rows` must be sorted by - * value descending. Keeps the top `limit`; if the cursor-nearest series ranks - * past it, that series replaces the lowest kept row so it still shows (pass - * `undefined` for the pinned tooltip, which has no cursor). `hiddenCount` - * drives the "+N more" line. Exported for unit testing. - */ -export function getVisibleTooltipRows( - rows: T[], - nearestSeriesKey: string | undefined, - limit: number, -): { rows: T[]; hiddenCount: number } { - if (rows.length <= limit) { - return { rows, hiddenCount: 0 }; - } - const visible = rows.slice(0, limit); - if ( - nearestSeriesKey != null && - !visible.some(r => r.dataKey === nearestSeriesKey) - ) { - const nearest = rows.find(r => r.dataKey === nearestSeriesKey); - if (nearest != null) { - visible[visible.length - 1] = nearest; - } - } - return { rows: visible, hiddenCount: rows.length - visible.length }; -} - -const StackedBarWithOverlap = (props: BarProps) => { - const { x, y, width, fill } = props; - // `height` may arrive as a string, so coerce it to a number before the - // arithmetic below. - const height = - typeof props.height === 'number' ? props.height : Number(props.height ?? 0); - // Add a tiny bit to the height to create overlap. Otherwise there's a gap - return ( - 0 ? height + 0.5 : 0} - fill={fill} - /> - ); -}; - -type CaptureActiveDotProps = { - /** - * Called with each series' active-point pixel Y. This is a stable callback - * (not the ref itself) so Recharts, which stores this element's props in its - * Immer-backed store and freezes them, never freezes the underlying Map — - * the write happens on the ref captured in the callback's closure instead. - */ - onCapture: (dataKey: string, cy: number) => void; - cx?: number; - cy?: number; - dataKey?: string | number; - r?: number; - fill?: string; - stroke?: string; - strokeWidth?: number; -}; - -/** - * Active dot for an Area series. Records the active point's pixel Y (`cy`) - * via `onCapture`, keyed by dataKey, then draws the same dot Recharts - * renders by default. Recharts clones this element with the active-point - * props (cx, cy, dataKey, r, fill, stroke, strokeWidth) during the render - * that precedes the tooltip, so the capture is current when the tooltip reads - * it to find the series nearest the cursor. - */ -function CaptureActiveDot({ - onCapture, - cx, - cy, - dataKey, - r, - fill, - stroke, - strokeWidth, -}: CaptureActiveDotProps) { - if (dataKey != null && typeof cy === 'number' && Number.isFinite(cy)) { - // Written synchronously during render so the tooltip, which Recharts - // renders after the graphical items in the same commit, reads the - // current frame's positions rather than the previous frame's. - onCapture(String(dataKey), cy); - } - if (typeof cx !== 'number' || typeof cy !== 'number') { - return null; - } - return ( - - ); -} - -/** - * Compute the unique set of hexes referenced by `` defs - * inside MemoChart. Exported so a unit test can pin the dedup-and-union - * behavior without standing up a full recharts render (which jsdom - * struggles with at the container-sized SVG layer). - * - * Includes every categorical hex up front so any positional `` - * fill resolves, then unions in semantic hexes returned by the - * `getChartColor{Info,Success,Warning,Error}` helpers; those land in - * `lineData[].color` and would otherwise be missing a matching def. - * `undefined` colors are filtered so `c.replace('#', '')` can't throw - * on a future caller that leaves a series color unset. - */ -export function collectMemoChartGradientHexes( - lineData: { color?: string }[], -): string[] { - return Array.from( - new Set([ - ...COLORS, - ...lineData - .map(ld => ld.color) - .filter((c): c is string => typeof c === 'string'), - ]), - ); -} - -/** - * A tick under MAGNITUDE_THRESHOLD gets at most this many decimal places, - * regardless of a chart's configured Decimals (which can go up to 10 - see - * NumberFormat.tsx). A tick >= MAGNITUDE_THRESHOLD is always an integer (see - * formatAxisTick) and so isn't governed by this - only a label under the - * threshold, with its own decimals, can make it wide. - * - * `` leaves a few dozen px for the label itself - * after Recharts' own tickSize + tickMargin. Measured in Chrome at 11px IBM - * Plex Mono (the tick font, monospace): every character costs ~6.6px, and - * 5 characters is the most that fits. At 2 decimals, "9.99" (4 chars) fits - * with room to spare, and adding a single extra character - a negative - * sign ("-9.99") or a percent suffix ("9.99%") - still exactly fits at 5. - * A 2-digit integer part pushes either of those over (6 chars, e.g. - * "-99.99"/"99.99%" both clip), which is why MAGNITUDE_THRESHOLD is 10, not - * 100: it trades a wider decimal-preserving range for values that are both - * negative and percent-formatted (out of scope here) for one that's safe - * for plain positive numbers, percent, and negative numbers each on their - * own - the only combination this codebase's charts have needed decimals - * for so far. 2 decimals is also enough to keep any value >= 0.005 - * distinguishable from 0, the failure this cap exists to fix. - */ -const MAX_AXIS_MANTISSA = 2; - -/** See MAX_AXIS_MANTISSA's comment for the width math behind this value. */ -const MAGNITUDE_THRESHOLD = 10; - -/** - * Y-axis tick label formatter. Exported so a unit test can pin the - * mantissa-precedence behavior without rendering recharts. - * - * `average` and `unit` are always forced (compact abbreviation like `1.2k` - * reads better on an axis than a series' configured unit repeated on every - * tick). For a tick at or past MAGNITUDE_THRESHOLD, mantissa is always 0 - - * large numbers stay `200`/`1k`/`256 MB`, never `200.00`/`1.23k`/`256.0 MB`, - * however many decimals the chart's Number Format configures. Below the - * threshold, an explicit axisNumberFormat.mantissa is honored (capped at - * MAX_AXIS_MANTISSA) rather than forced to 0. Without that, a chart whose - * configured Decimals produces correct tooltip/legend values (e.g. `0.14`) - * would still round every axis tick to `0` for any series whose values live - * under 1 (fractional Prometheus gauges, ratios, etc.). A tick of exactly 0 - * always short-circuits to an integer too - it's already unambiguous, and - * doesn't need the decimal rescue this formatter exists to provide. - * - * An explicit mantissa is the common case, not a rare one, which is why the - * large-magnitude branch can't just honor it: HyperDX's own bundled - * dashboard templates (go-runtime.json et al.) set mantissa on lines/byte/ - * percent tiles for tooltip readability, with values well above the - * near-zero problem this formatter fixes. - * - * `formatNumber` multiplies a percent-output value by 100 before applying - * mantissa (a percent tile's raw value is a 0-1 ratio, e.g. `0.25` for - * "25%"), so the magnitude check runs against that same displayed value, - * not the raw one - otherwise every percent tile would take the small- - * magnitude branch regardless of how large the rendered percentage is. - * - * This diverges from DBHeatmapChart's tickFormatter (magnitude-aware at a - * >= 1 threshold, but ignoring configured mantissa entirely for values - * under it) - a deliberate difference in both the threshold and whether - * configured mantissa is honored at all, not an oversight. - */ -export function formatAxisTick( - value: number, - axisNumberFormat?: NumberFormat, -): string { - if (!axisNumberFormat) { - return new Intl.NumberFormat('en-US', { - notation: 'compact', - compactDisplay: 'short', - }).format(value); - } - - const displayed = axisNumberFormat.output === 'percent' ? value * 100 : value; - - return formatNumber(value, { - ...axisNumberFormat, - mantissa: - displayed === 0 || Math.abs(displayed) >= MAGNITUDE_THRESHOLD - ? 0 - : Math.min(axisNumberFormat.mantissa ?? 0, MAX_AXIS_MANTISSA), - average: true, - unit: undefined, - }); -} - -export const MemoChart = memo(function MemoChart({ - graphResults, - setIsClickActive, - refreshClickActive, - isClickActive, - dateRange, - lineData, - referenceLines, - annotations, - logReferenceTimestamp, - displayType = DisplayType.Line, - axisNumberFormat, - fallbackNumberFormat, - tooltipNumberFormatsByKey, - isLoading, - timestampKey = 'ts_bucket', - onTimeRangeSelect, - showLegend = true, - previousPeriodOffsetSeconds, - selectedSeriesNames, - onToggleSeries, - onClearSeriesSelection, - granularity, - dateRangeEndInclusive = true, - fitYAxisToData = false, -}: { - graphResults: any[]; - setIsClickActive: (v: ActiveClickPayload | undefined) => void; - /** - * In-place refresh of the open pin's frozen snapshot (rows only), without the - * cross-chart pin-dismiss broadcast setIsClickActive performs. Used by the - * resync effect. Falls back to setIsClickActive when not provided. - */ - refreshClickActive?: (v: ActiveClickPayload | undefined) => void; - isClickActive: ActiveClickPayload | undefined; - dateRange: [Date, Date] | Readonly<[Date, Date]>; - lineData: LineData[]; - referenceLines?: React.ReactNode; - /** - * Event markers (alerts, releases, …) drawn as dashed vertical lines with a - * label above. Passed as data rather than pre-rendered elements so the chart - * can clamp them to its own x-axis domain. Distinct from `referenceLines` - * (threshold lines). - */ - annotations?: ChartAnnotation[]; - displayType?: DisplayType; - axisNumberFormat?: NumberFormat; - fallbackNumberFormat?: NumberFormat; - tooltipNumberFormatsByKey: Map; - logReferenceTimestamp?: number; - isLoading?: boolean; - timestampKey?: string; - onTimeRangeSelect?: (start: Date, end: Date) => void; - showLegend?: boolean; - previousPeriodOffsetSeconds?: number; - selectedSeriesNames?: Set; - onToggleSeries?: (seriesName: string, isShiftKey?: boolean) => void; - /** Clear the current series isolation; renders a "Show All Series" button when set. */ - onClearSeriesSelection?: () => void; - granularity: string; - dateRangeEndInclusive?: boolean; - /** - * When true, the y-axis lower bound is the minimum of the displayed data - * (with padding) instead of zero. - **/ - fitYAxisToData?: boolean; -}) { - const _id = useId(); - const id = _id.replace(/:/g, ''); - - // recharts sync group, scoped via context (see chartSync). - const syncId = useChartSyncId(); - - const [isHovered, setIsHovered] = useState(false); - - // Filled by each Area's active dot with the series' active-point pixel Y, - // keyed by dataKey, so the tooltip can bold the series nearest the cursor. - // Read during the same render that draws the active dots. - const activePointYByKeyRef = useRef>(new Map()); - - // Stable writer passed to the active-dot element instead of the ref itself. - // Recharts freezes the props of graphical-item elements in its Immer store; - // passing a callback (rather than the Map) keeps the mutation on the - // closed-over ref, which is never frozen. - const captureActivePointY = useCallback((dataKey: string, cy: number) => { - activePointYByKeyRef.current.set(dataKey, cy); - }, []); - - // Key of the series whose line is nearest the cursor, lifted into state so - // the chart can emphasize that line (thicker stroke) and fade the rest. - // Set from the chart's mouse-move using the pixel Y the active dots captured - // on the prior frame; the one-frame lag is imperceptible and settles as soon - // as the pointer stops. The tooltip derives the same nearest row itself, - // same-frame, for its own bolding and dimming. - const [nearestSeriesKey, setNearestSeriesKey] = useState< - string | undefined - >(); - - const ChartComponent = useMemo( - () => (displayType === DisplayType.StackedBar ? BarChart : AreaChart), // LineChart; - [displayType], - ); - - const visibleLineData = useMemo( - () => getVisibleLineData(lineData, selectedSeriesNames), - [lineData, selectedSeriesNames], - ); - - // Series for the pinned tooltip's drill-down list: selection applied but NOT - // clamped to HARD_LINES_LIMIT, so "load all series" reveals series that were - // materialized (up to the render cap) yet not drawn. Kept separate from - // visibleLineData so the drawn chart stays bounded at HARD_LINES_LIMIT. - const tooltipLineData = useMemo( - () => getSelectedLineData(lineData, selectedSeriesNames), - [lineData, selectedSeriesNames], - ); - - const lines = useMemo(() => { - return visibleLineData.map(ld => { - const key = ld.dataKey; - const color = ld.color; - const strokeDasharray = ld.isDashed ? '4 3' : '0'; - const seriesName = getSeriesDisplayName(ld); - - return displayType === 'stacked_bar' ? ( - } - /> - ) : ( - on hover. - className={seriesClassName(id, key)} - activeDot={} - // Fill is always the gradient. Hiding it on hover is a CSS class - // toggle (styles.chartHovered), not a prop swap — swapping it here - // re-created every on each hover enter/leave (hover churn). - fill={`url(#time-chart-lin-grad-${id}-${color?.replace('#', '').toLowerCase()})`} - strokeDasharray={strokeDasharray} - name={seriesName} - isAnimationActive={false} - connectNulls - /> - ); - }); - }, [visibleLineData, displayType, id, captureActivePointY]); - - // Nearest-cursor emphasis (thicken the nearest line, fade the rest) applied - // via a tiny scoped - ); - }, [nearestSeriesKey, visibleLineData.length, id]); - - const yAxisDomain: AxisDomain = useMemo(() => { - const hasSelection = hasSeriesSelection(selectedSeriesNames); - - // Fitting the y-axis lower bound to the data only applies to line charts. - // Bar charts are always anchored at zero so the bar lengths stay - // proportional to their values. - const shouldFitYAxis = - fitYAxisToData && displayType !== DisplayType.StackedBar; - - // The data min/max is only needed to either zoom into a selection or to - // fit the lower bound to the data. When neither applies, let Recharts - // auto-calculate the upper bound while pinning the lower bound to zero. - if (!hasSelection && !shouldFitYAxis) { - return [0, 'auto']; - } - - // Calculate domain based on visible series (all series when there's no - // explicit selection). - let minValue = Infinity; - let maxValue = -Infinity; - - graphResults.forEach(dataPoint => { - lineData.forEach(ld => { - const seriesName = ld.displayName || ld.dataKey; - // Only consider visible series - if (!hasSelection || selectedSeriesNames.has(seriesName)) { - const value = dataPoint[ld.dataKey]; - if (typeof value === 'number' && !isNaN(value)) { - minValue = Math.min(minValue, value); - maxValue = Math.max(maxValue, value); - } - } - }); - }); - - // If we found valid values, return them with some padding - if (minValue !== Infinity && maxValue !== -Infinity) { - const padding = (maxValue - minValue) * 0.05; // 5% padding - // When fitting to data, allow the lower bound to follow the data - // minimum; otherwise keep it pinned at zero. The 5% padding must not - // drag the axis below zero unless the data itself is negative, so - // clamp at zero whenever the minimum is non-negative. - const lowerBound = - shouldFitYAxis && minValue < 0 - ? minValue - padding - : Math.max(0, minValue - padding); - const upperBound = maxValue + padding; - return [lowerBound, upperBound]; - } - - return ['auto', 'auto']; - }, [ - graphResults, - lineData, - selectedSeriesNames, - fitYAxisToData, - displayType, - ]); - - const [containerWidth, setContainerWidth] = useState(0); - - // The chart's outer positioned container. Used to convert a pointer's - // viewport clientX into a stable container-relative X for measuring - // drag-to-zoom distance — a single origin that is always defined, unlike the - // chart's activeCoordinate (null off a data point) or a child SVG element's - // offsetX (relative to whichever bar/path is under the pointer). - const containerRef = useRef(null); - const getContainerX = useCallback((e?: { clientX?: number } | null) => { - if (e?.clientX == null || containerRef.current == null) return undefined; - return e.clientX - containerRef.current.getBoundingClientRect().left; - }, []); - - // Build the pinned-tooltip payload for the clicked bucket from a recharts - // chart event `state`, including the viewport coords Mantine anchors to. - const buildActivePayloadFromState = useCallback( - (state?: { - activeCoordinate?: { x?: number; y?: number }; - activeLabel?: string | number; - }): ActiveClickPayload | undefined => { - const chartX = state?.activeCoordinate?.x; - const chartY = state?.activeCoordinate?.y; - const activeLabel = getActiveLabel(state); - if (chartX == null || chartY == null || activeLabel == null) { - return undefined; - } - const activeRow = graphResults.find( - row => String(row[timestampKey]) === activeLabel, - ); - // Build from tooltipLineData (uncapped), not visibleLineData: the pinned - // drill-down list may show more series than are drawn. - const activePayload = buildActiveClickSeries(tooltipLineData, activeRow); - if (activePayload.length === 0) { - return undefined; - } - const containerRect = containerRef.current?.getBoundingClientRect(); - const anchor = toViewportPoint(containerRect, { x: chartX, y: chartY }); - return { - viewportX: anchor.x, - viewportY: anchor.y, - activeLabel, - activePayload, - }; - }, - [graphResults, timestampKey, tooltipLineData], - ); - - // Keep the pinned tooltip's frozen snapshot in sync with its series set. - // The snapshot's rows are captured once at click time from `tooltipLineData`; - // when that set changes underneath an open pin — most notably after "load all - // series" materializes the previously-capped series — the frozen rows (and - // the "+N more" overflow derived from them) would otherwise stay stale, so - // clicking "load all" would leave a phantom "+N more" and never surface the - // newly-loaded rows. Rebuild the rows for the same clicked bucket from the - // current data while preserving the click-time anchor coords. - useEffect(() => { - if (isClickActive == null) return; - const activeRow = graphResults.find( - row => String(row[timestampKey]) === isClickActive.activeLabel, - ); - const nextPayload = buildActiveClickSeries(tooltipLineData, activeRow); - // No numeric value at the pinned bucket anymore (e.g. the series vanished); - // leave the existing snapshot rather than dismissing a still-anchored pin. - if (nextPayload.length === 0) return; - // Only update when the series set actually changed, so ordinary re-renders - // (hover, live-range ticks) don't churn state or reset scroll position. - if (sameActiveClickSeries(isClickActive.activePayload, nextPayload)) return; - // In-place refresh (no cross-chart pin-dismiss broadcast); fall back to - // setIsClickActive when the refresh callback isn't wired. - (refreshClickActive ?? setIsClickActive)({ - ...isClickActive, - activePayload: nextPayload, - }); - }, [ - isClickActive, - graphResults, - timestampKey, - tooltipLineData, - refreshClickActive, - setIsClickActive, - ]); - - // Recharts computes bar width from the smallest gap between ticks on a - // numerical XAxis. With a single data point there are no gaps, so the - // computed width is 0 and bars become invisible. Provide an explicit - // barSize for that case, sized to most of the drawable width (the - // xAxisDomain already spans exactly one granularity for StackedBar charts). - const singlePointBarSize = useMemo(() => { - if (displayType !== DisplayType.StackedBar) return undefined; - if (graphResults.length !== 1) return undefined; - const drawableWidth = Math.max( - 0, - containerWidth - Y_AXIS_WIDTH - SINGLE_POINT_BAR_RIGHT_PADDING, - ); - if (drawableWidth <= 0) return undefined; - return Math.max( - 1, - Math.floor(drawableWidth * SINGLE_POINT_BAR_WIDTH_RATIO), - ); - }, [displayType, graphResults.length, containerWidth]); - - const formatTime = useFormatTime(); - const xTickFormatter = useCallback( - (value: number, index: number) => { - return formatTime(value * 1000, { - format: index === 0 ? 'normal' : 'time', - }); - }, - [formatTime], - ); - - const tickFormatter = useCallback( - (value: number) => formatAxisTick(value, axisNumberFormat), - [axisNumberFormat], - ); - - const [highlightStart, setHighlightStart] = useState(); - const [highlightEnd, setHighlightEnd] = useState(); - const mouseDownPosRef = useRef(null); - - // Tracks the time range that was displayed before the user brushed to zoom - // in, so a "Reset zoom" control can restore it (mirrors Highcharts). It holds - // the earliest pre-zoom range across consecutive zoom-ins so resetting jumps - // all the way back to where zooming started. - const [zoomOrigin, setZoomOrigin] = useState<[Date, Date] | null>(null); - // Set right before we trigger our own brush-zoom so the dateRange effect can - // tell an internal zoom apart from an external time-range change. - const justZoomedRef = useRef(false); - // Set on a completed brush-zoom so the synthetic click that follows mouseup - // is swallowed (instead of freezing a stale drill-down tooltip). Kept - // separate from justZoomedRef and consumed/cleared by onClick, because the - // dateRange effect may never run when the post-zoom range is value-equal. - const suppressNextClickRef = useRef(false); - const prevDateRangeRef = useRef<[number, number] | null>(null); - - // Clear the reset-zoom affordance whenever the time range changes for a - // reason other than our own brush-zoom (e.g. the time picker or live tail), - // so the button never restores a stale range. Compared by value because - // `dateRange` can be a fresh array reference even when unchanged. - useEffect(() => { - const from = dateRange[0].getTime(); - const to = dateRange[1].getTime(); - const prev = prevDateRangeRef.current; - const changed = prev == null || prev[0] !== from || prev[1] !== to; - prevDateRangeRef.current = [from, to]; - - // A brush-zoom sets justZoomedRef; consume it here so the range change it - // caused doesn't clear zoomOrigin. Clear it even when the range didn't - // actually change (a value-equal zoom), so it can't leak into a later - // unrelated range change and wrongly preserve a stale zoomOrigin. - const wasInternalZoom = justZoomedRef.current; - justZoomedRef.current = false; - - if (!changed) { - return; - } - if (wasInternalZoom) { - return; - } - setZoomOrigin(null); - }, [dateRange]); - - const handleResetZoom = useCallback(() => { - if (zoomOrigin == null) { - return; - } - const [start, end] = zoomOrigin; - setZoomOrigin(null); - onTimeRangeSelect?.(new Date(start.getTime()), new Date(end.getTime())); - }, [zoomOrigin, onTimeRangeSelect]); - - const lineDataMap = useMemo(() => { - const map: { [key: string]: LineData } = {}; - lineData.forEach(ld => { - map[ld.dataKey] = ld; - }); - return map; - }, [lineData]); - - // Memoize the tooltip `content` element: recharts re-evaluates it every hover - // frame, so a fresh element each render defeats HDXLineChartTooltip's memo. - // Refs are stable, so only the listed values are deps. - const hoverTooltipContent = useMemo( - () => ( - - ), - [ - fallbackNumberFormat, - tooltipNumberFormatsByKey, - lineDataMap, - previousPeriodOffsetSeconds, - ], - ); - - // Latest values the mouse handlers read, in a ref so the handlers below can - // be stable useCallbacks. Recharts re-runs its event wiring when a handler - // prop's identity changes, so a stable reference avoids that per-render churn. - const handlerStateRef = useRef({ - isClickActive, - highlightStart, - highlightEnd, - dateRange, - onTimeRangeSelect, - }); - // Updated in an effect (not during render); the one-commit lag is harmless - // since these are only read in event handlers, which fire after commit. - useEffect(() => { - handlerStateRef.current = { - isClickActive, - highlightStart, - highlightEnd, - dateRange, - onTimeRangeSelect, - }; - }, [ - isClickActive, - highlightStart, - highlightEnd, - dateRange, - onTimeRangeSelect, - ]); - - const handleMouseEnter = useCallback(() => setIsHovered(true), []); - - const handleMouseLeave = useCallback(() => { - setIsHovered(false); - setNearestSeriesKey(undefined); - setHighlightStart(undefined); - setHighlightEnd(undefined); - mouseDownPosRef.current = null; - }, []); - - const handleMouseDown = useCallback( - (state: ChartMouseState, e?: { nativeEvent?: { clientX?: number } }) => { - // Record the drag start: the active bucket label and a container-relative - // pointer X (always defined, single origin) for measuring drag distance. - const chartX = getContainerX(e?.nativeEvent); - const downLabel = getActiveLabel(state); - if (downLabel != null && chartX != null) { - setHighlightStart(downLabel); - mouseDownPosRef.current = chartX; - } - }, - [getContainerX], - ); - - const handleMouseMove = useCallback( - (state: ChartMouseState) => { - setIsHovered(true); - - const { isClickActive, highlightStart } = handlerStateRef.current; - - // Track which series' line is nearest the cursor so the lines can - // emphasize it. The active dots captured their pixel Y on the prior frame; - // comparing the pointer's chartY picks the nearest line. Skip while a - // click-frozen tooltip is shown, matching the tooltip, and only set state - // when the key changes to keep re-renders rare. - const chartY = state?.activeCoordinate?.y; - const activePointYByKey = activePointYByKeyRef.current; - const nextNearest = - isClickActive == null && activePointYByKey.size > 1 && chartY != null - ? findNearestSeriesKey( - activePointYByKey, - Array.from(activePointYByKey.keys()), - chartY, - NEAREST_SERIES_MAX_DISTANCE_PX, - ) - : undefined; - setNearestSeriesKey(prev => (prev === nextNearest ? prev : nextNearest)); - - const moveLabel = getActiveLabel(state); - if (highlightStart != null && moveLabel != null) { - setHighlightEnd(moveLabel); - setIsClickActive(undefined); // Clear out any click state as we're highlighting - } - }, - [setIsClickActive], - ); - - const handleMouseUp = useCallback( - (state: ChartMouseState, e?: { nativeEvent?: { clientX?: number } }) => { - const MIN_DRAG_DISTANCE = 20; // Minimum horizontal drag distance in pixels - let dragDistance = 0; - - const { highlightStart, highlightEnd, dateRange, onTimeRangeSelect } = - handlerStateRef.current; - - // Measure against the same container-relative origin recorded on mouse - // down so the distance is never skewed or dropped when the pointer maps - // to no data point. - const chartX = getContainerX(e?.nativeEvent); - if (mouseDownPosRef.current != null && chartX != null) { - dragDistance = Math.abs(chartX - mouseDownPosRef.current); - } - - const activeLabel = getActiveLabel(state); - if (activeLabel != null && highlightStart === activeLabel) { - // If it's just a click, don't zoom - setHighlightStart(undefined); - setHighlightEnd(undefined); - mouseDownPosRef.current = null; - } else if ( - highlightStart != null && - highlightEnd != null && - dragDistance >= MIN_DRAG_DISTANCE - ) { - try { - // Remember the range we're zooming away from so "Reset zoom" can - // restore it. Keep the earliest origin across consecutive zooms. - const originStart = dateRange[0]; - const originEnd = dateRange[1]; - setZoomOrigin(prev => prev ?? [originStart, originEnd]); - // The synthetic click after this drag must be swallowed regardless of - // whether a range change follows; onClick consumes and clears this. - suppressNextClickRef.current = true; - // Only tell the [dateRange] effect to preserve zoomOrigin when a - // range change will actually happen; without onTimeRangeSelect the - // range never changes and the effect never runs. - if (onTimeRangeSelect != null) { - justZoomedRef.current = true; - } - // Order the range numerically — the labels are epoch-second strings, - // so a lexicographic compare would misorder values of differing - // digit length. - const startSec = Number(highlightStart); - const endSec = Number(highlightEnd); - const lowSec = Math.min(startSec, endSec); - const highSec = Math.max(startSec, endSec); - onTimeRangeSelect?.( - new Date(lowSec * 1000), - new Date(highSec * 1000), - ); - } catch (err) { - console.error('failed to highlight range', err); - justZoomedRef.current = false; - setZoomOrigin(null); - } - setHighlightStart(undefined); - setHighlightEnd(undefined); - mouseDownPosRef.current = null; - } else { - // Drag was too short, clear the highlight - setHighlightStart(undefined); - setHighlightEnd(undefined); - mouseDownPosRef.current = null; - } - }, - [getContainerX], - ); - - const handleClick = useCallback( - (state: ChartMouseState, e: { stopPropagation: () => void }) => { - // A brush-to-zoom ends with a synthetic click; skip that one click so we - // don't freeze a drill-down tooltip with now-stale, pre-zoom data. - // Consume-and-clear the flag here so a value-equal zoom (which never - // triggers the dateRange effect) can't leave it stuck and suppress every - // later click. - if (suppressNextClickRef.current) { - suppressNextClickRef.current = false; - e.stopPropagation(); - return; - } - const { highlightStart } = handlerStateRef.current; - // Freeze a tooltip at the clicked point. The builder mirrors the series - // actually drawn (legend selection + HARD_LINES_LIMIT). - const clickPayload = - highlightStart == null ? buildActivePayloadFromState(state) : undefined; - if (clickPayload != null) { - setIsClickActive(clickPayload); - // Pinned replaces hover; drop line emphasis to match. - setNearestSeriesKey(undefined); - } else { - // We clicked on the chart but outside of a line - setIsClickActive(undefined); - } - - // TODO: Properly detect clicks outside of the fake tooltip - e.stopPropagation(); - }, - [buildActivePayloadFromState, setIsClickActive], - ); - - // Typed as the tuple it actually is (assignable to AxisDomain) so the - // annotation helpers can take it without an unsafe cast. - const xAxisDomain: [number, number] = useMemo(() => { - let startTime = toStartOfInterval(dateRange[0], granularity); - let endTime = toStartOfInterval(dateRange[1], granularity); - const endTimeIsBoundaryAligned = isSameSecond(dateRange[1], endTime); - if (endTimeIsBoundaryAligned && !dateRangeEndInclusive) { - endTime = sub(endTime, { - seconds: convertGranularityToSeconds(granularity), - }); - } - - // For bar charts, extend the domain in both directions by half a granularity unit - // so that the full bar width is within the bounds of the chart - if (displayType === DisplayType.StackedBar) { - const halfGranularitySeconds = - convertGranularityToSeconds(granularity) / 2; - startTime = sub(startTime, { seconds: halfGranularitySeconds }); - endTime = add(endTime, { seconds: halfGranularitySeconds }); - } - - return [startTime.getTime() / 1000, endTime.getTime() / 1000]; - }, [dateRange, granularity, dateRangeEndInclusive, displayType]); - - // Alert/event markers as dashed lines, clamped to the chart's x-axis domain so - // an edge marker (e.g. an alert already firing at window open) stays visible - // instead of being dropped. Labels float in the reserved top headroom. - // Tint each marker to match the series it describes and drop the ones that - // can't be tied to anything on this chart — see `resolveAnnotationSeries`. - const coloredAnnotations = useMemo(() => { - if (!annotations?.length) { - return annotations; - } - return resolveAnnotationSeries(annotations, group => - getSeriesColorForGroup(lineData, group), - ); - }, [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(null); - - const annotationElements = useMemo(() => { - if (!coloredAnnotations?.length) { - return null; - } - return getAnnotationElements(coloredAnnotations, { - domain: xAxisDomain, - // Drawable width, so markers too close together share one label. Zero on - // the first paint (before ResponsiveContainer measures), which the - // renderer treats as "label everything". - plotWidth: Math.max(0, containerWidth - Y_AXIS_WIDTH), - }); - }, [coloredAnnotations, xAxisDomain, containerWidth]); - - return ( -
's fill prop, so the chart's ~N Area - // elements are not re-created on every hover enter/leave. - // `rr-block` tells the HyperDX/rrweb session-replay recorder to capture - // this chart as a placeholder rather than serializing its (very large) - // SVG DOM on every mutation — the dominant session-replay cost on - // high-cardinality dashboards. - className={cx( - 'rr-block', - styles.chartRoot, - isHovered && styles.chartHovered, - )} - // Scopes nearestSeriesStyle to this chart instance. - data-chart-id={id} - style={{ position: 'relative', width: '100%', height: '100%' }} - > - {nearestSeriesStyle} - {hoveredAnnotation != null && ( - - )} - - { - const w = width ?? 1; - setContainerWidth(prev => (prev === w ? prev : w)); - }} - className={isLoading ? 'effect-pulse' : ''} - > - - - {/* Gradient defs cover every hex that any fill may reference. - `COLORS` (the unified categorical palette) is included up-front - as a baseline; semantic colors returned by the - `getChartColor{Info,Success,Warning,Error}` helpers can also - appear in `lineData[].color` (e.g. info-level log series - resolve to `--color-chart-info`, chart blue `#437eef`, on both - brands, which matches categorical slot 0). Union them here so the - referenced `url(#time-chart-lin-grad-…)` always exists. */} - {collectMemoChartGradientHexes(lineData).map(c => { - return ( - - - - - ); - })} - - {isHovered && ( - - )} - - - {lines} - {/* HOVER tooltip (also drives cross-chart shadow tooltips via syncId). - Hidden once a point is clicked, where the pinned tooltip takes over. - Portaled to body so HDXLineChartTooltip can self-position (see its - docblock) and escape the chart's bounds near an edge. */} - {isClickActive == null && ( - - )} - {referenceLines} - {annotationElements} - {laidOutAnnotations != null && ( - - } - /> - )} - {highlightStart && highlightEnd ? ( - - ) : null} - {showLegend && ( - - } - offset={-100} - /> - )} - {/** Needs to be at the bottom to prevent re-rendering */} - {isClickActive != null ? ( - // The x-axis is numeric (scale="time"); pass a number so the marker - // positions without relying on the axis coercing a string. - - ) : null} - {logReferenceTimestamp != null ? ( - - ) : null} - - -
- ); -}); diff --git a/packages/app/src/HDXMultiSeriesTimeChart/ChartLegend.tsx b/packages/app/src/HDXMultiSeriesTimeChart/ChartLegend.tsx new file mode 100644 index 0000000000..b7a0e4fb6d --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/ChartLegend.tsx @@ -0,0 +1,174 @@ +import { memo, useMemo, useState } from 'react'; +import cx from 'classnames'; +import { Popover } from '@mantine/core'; + +import { type LineData } from '@/ChartUtils'; +import { truncateMiddle } from '@/utils'; + +import { hasSeriesSelection } from './chartData'; +import { MAX_LEGEND_ITEMS } from './constants'; + +import styles from '@styles/HDXLineChart.module.scss'; + +function ExpandableLegendItem({ + entry, + expanded, + isSelected, + isDisabled, + onToggle, +}: { + entry: any; + expanded?: boolean; + isSelected?: boolean; + isDisabled?: boolean; + onToggle?: (isShiftKey: boolean) => void; +}) { + const [_expanded, setExpanded] = useState(false); + const isExpanded = _expanded || expanded; + + return ( + { + if (onToggle) { + onToggle(e.shiftKey); + } else { + setExpanded(v => !v); + } + }} + title={ + isSelected + ? 'Click to show all (Shift+click to deselect)' + : 'Click to show only this (Shift+click for multi-select)' + } + > +
+ + + +
+ {isExpanded || isSelected + ? entry.value + : truncateMiddle(`${entry.value}`, 35)} +
+ ); +} + +export const LegendRenderer = memo<{ + payload?: { + dataKey: string; + value: string; + color: string; + }[]; + lineDataMap: { [key: string]: LineData }; + allLineData?: LineData[]; + selectedSeries?: Set; + onToggleSeries?: (seriesName: string, isShiftKey?: boolean) => void; +}>(props => { + const { payload, lineDataMap, allLineData, selectedSeries, onToggleSeries } = + props; + + const hasSelection = hasSeriesSelection(selectedSeries); + + // Use allLineData to ensure all series are always shown in legend + const allSeriesPayload = useMemo(() => { + if (allLineData?.length) { + return allLineData.map(ld => ({ + dataKey: ld.dataKey, + value: ld.displayName || ld.dataKey, + color: ld.color, + payload: { strokeDasharray: ld.isDashed ? '4 3' : '0' }, + })); + } + return payload ?? []; + }, [allLineData, payload]); + + const sortedLegendItems = useMemo(() => { + // Order items such that current and previous period lines are consecutive + const currentPeriodKeyIndex = new Map(); + allSeriesPayload.forEach((line, index) => { + const currentPeriodKey = + lineDataMap[line.dataKey]?.currentPeriodKey || ''; + if (!currentPeriodKeyIndex.has(currentPeriodKey)) { + currentPeriodKeyIndex.set(currentPeriodKey, index); + } + }); + + // Copy before sorting: when this comes from Recharts' legend payload it is + // kept in the Immer-backed store and frozen, so an in-place sort throws. + return [...allSeriesPayload].sort((a, b) => { + const keyA = lineDataMap[a.dataKey]?.currentPeriodKey ?? ''; + const keyB = lineDataMap[b.dataKey]?.currentPeriodKey ?? ''; + + const indexA = currentPeriodKeyIndex.get(keyA) ?? 0; + const indexB = currentPeriodKeyIndex.get(keyB) ?? 0; + + return indexB - indexA || a.dataKey.localeCompare(b.dataKey); + }); + }, [allSeriesPayload, lineDataMap]); + + const shownItems = sortedLegendItems.slice(0, MAX_LEGEND_ITEMS); + const restItems = sortedLegendItems.slice(MAX_LEGEND_ITEMS); + + return ( +
+ {shownItems.map((entry, index) => { + const isSelected = !!selectedSeries?.has(entry.value); + const isDisabled = hasSelection && !isSelected; + return ( + onToggleSeries?.(entry.value, isShiftKey)} + /> + ); + })} + {restItems.length ? ( + + +
+ +{restItems.length} more +
+
+ +
+ {restItems.map((entry, index) => { + const isSelected = !!selectedSeries?.has(entry.value); + const isDisabled = hasSelection && !isSelected; + return ( + + onToggleSeries?.(entry.value, isShiftKey) + } + /> + ); + })} +
+
+
+ ) : null} +
+ ); +}); diff --git a/packages/app/src/HDXMultiSeriesTimeChart/ChartTooltipContent.tsx b/packages/app/src/HDXMultiSeriesTimeChart/ChartTooltipContent.tsx new file mode 100644 index 0000000000..00560fa721 --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/ChartTooltipContent.tsx @@ -0,0 +1,182 @@ +import { memo, useMemo } from 'react'; +import { withErrorBoundary } from 'react-error-boundary'; + +import { findNearestSeriesKey, type LineData } from '@/ChartUtils'; +import { + ChartTooltipContainer, + ChartTooltipHeader, + toViewportPoint, + useChartTooltipZIndex, +} from '@/components/charts/ChartTooltip'; +import type { NumberFormat } from '@/types'; + +import { getVisibleTooltipRows } from './chartData'; +import { + MAX_TOOLTIP_ROWS, + NEAREST_SERIES_MAX_DISTANCE_PX, + TOOLTIP_POINT_OFFSET_PX, +} from './constants'; +import { TooltipItem, type TooltipPayload } from './TooltipItem'; + +import styles from '@styles/HDXLineChart.module.scss'; + +type HDXLineChartTooltipProps = { + lineDataMap: { [keyName: string]: LineData }; + previousPeriodOffsetSeconds?: number; + numberFormat?: NumberFormat; + numberFormatByKey: Map; + /** Per-series active-point pixel Y, captured by the Area active dots. */ + activePointYByKeyRef: React.MutableRefObject>; + /** The chart's outer container; its viewport rect anchors this tooltip. */ + containerRef: React.MutableRefObject; +} & Record; + +/** + * The recharts `` content used for the HOVER tooltip (on the hovered + * chart and its synced followers). Clicking pins ChartSeriesTooltip instead. + * + * Because it's given `portal={document.body}`, recharts skips its own transform + * positioning, so this content self-anchors at the active point with + * `position: fixed` (container rect + `coordinate`) — matching the pinned + * tooltip's anchor, and escaping the chart's bounds so edges aren't clipped. + */ +export const HDXLineChartTooltip = withErrorBoundary( + memo((props: HDXLineChartTooltipProps) => { + const { + active, + payload, + label, + numberFormat, + numberFormatByKey, + lineDataMap, + previousPeriodOffsetSeconds, + activePointYByKeyRef, + containerRef, + } = props; + const typedPayload = payload as TooltipPayload[]; + + const tooltipZIndex = useChartTooltipZIndex(); + + const payloadByKey = useMemo( + () => new Map(typedPayload.map(p => [p.dataKey, p])), + [typedPayload], + ); + + if (active && payload && payload.length) { + // No onClose: hover renders the X hidden (kept for layout parity). + const header = ( + + ); + + // Bold the line nearest the cursor by comparing pointer Y to each series' + // active-dot Y. The dots write their positions earlier in this same render + // (Recharts draws graphical items before the tooltip), so it's current. + const pointerY: number | undefined = props.coordinate?.y; + // eslint-disable-next-line react-hooks/refs + const activePointYByKey = activePointYByKeyRef?.current ?? undefined; + const nearestSeriesKey = + typedPayload.length > 1 + ? findNearestSeriesKey( + activePointYByKey, + typedPayload.map(p => p.dataKey), + pointerY, + NEAREST_SERIES_MAX_DISTANCE_PX, + ) + : undefined; + + // Anchor at the active point (see the component docblock for why fixed). + const pointX = props.coordinate?.x; + const pointY = props.coordinate?.y; + // eslint-disable-next-line react-hooks/refs + const containerRect = containerRef?.current?.getBoundingClientRect(); + const anchor = + typeof pointX === 'number' && + typeof pointY === 'number' && + containerRect != null + ? toViewportPoint(containerRect, { x: pointX, y: pointY }) + : undefined; + const anchorStyle: React.CSSProperties = + anchor != null + ? { + position: 'fixed', + left: anchor.x, + top: anchor.y + TOOLTIP_POINT_OFFSET_PX, + transform: 'translateX(-50%)', + pointerEvents: 'none', + // z-index must live here: recharts leaves the portaled wrapper + // `position: static`, where z-index has no effect. + zIndex: tooltipZIndex, + } + : {}; + + // Copy before sorting: Recharts 3 freezes the payload, so an in-place + // sort throws "this object has been frozen". + const sortedPayload = [...typedPayload].sort( + (a: TooltipPayload, b: TooltipPayload) => b.value - a.value, + ); + + // Cap how many rows are rendered per frame (see getVisibleTooltipRows). + const { rows: visiblePayload, hiddenCount: hiddenRowCount } = + getVisibleTooltipRows( + sortedPayload, + nearestSeriesKey, + MAX_TOOLTIP_ROWS, + ); + + return ( +
+ + {visiblePayload.map((p: TooltipPayload) => { + const previousKey = lineDataMap[p.dataKey]?.previousPeriodKey; + const isPreviousPeriod = previousKey === p.dataKey; + const previousPayload = + !isPreviousPeriod && previousKey + ? payloadByKey.get(previousKey) + : undefined; + const valueColumnName = + lineDataMap[p.dataKey]?.valueColumnName ?? p.dataKey; + const numberFormatForKey = + numberFormatByKey.get(valueColumnName) ?? numberFormat; + + return ( + + ); + })} + {hiddenRowCount > 0 && ( +
+ +{hiddenRowCount.toLocaleString()} more +
+ )} +
+
+ ); + } + return null; + }), + { + onError: console.error, + fallback: ( +
+ An error occurred while rendering the tooltip. +
+ ), + }, +); diff --git a/packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx new file mode 100644 index 0000000000..22d8f77d09 --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx @@ -0,0 +1,872 @@ +import { + memo, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; +import cx from 'classnames'; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Customized, + Legend, + ReferenceArea, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { DisplayType } from '@hyperdx/common-utils/dist/types'; + +import { useChartSyncId } from '@/chartSync'; +import { findNearestSeriesKey, LineData } from '@/ChartUtils'; +import { + AnnotationHitLayer, + type HoveredAnnotation, +} from '@/components/charts/AnnotationHitLayer'; +import { AnnotationTooltip } from '@/components/charts/AnnotationTooltip'; +import { ChartAnnotation } from '@/components/charts/chartAnnotations'; +import { ChartOverlayControls } from '@/components/charts/ChartOverlayControls'; +import { toViewportPoint } from '@/components/charts/ChartTooltip'; +import type { NumberFormat } from '@/types'; +import { useFormatTime } from '@/useFormatTime'; +import { COLORS } from '@/utils'; + +import { + type ActiveClickPayload, + buildActiveClickSeries, + type ChartMouseState, + formatAxisTick, + getActiveLabel, + getSelectedLineData, + getSeriesDisplayName, + getVisibleLineData, + hasSeriesSelection, + sameActiveClickSeries, + seriesClassName, +} from './chartData'; +import { LegendRenderer } from './ChartLegend'; +import { CaptureActiveDot, StackedBarWithOverlap } from './chartPrimitives'; +import { HDXLineChartTooltip } from './ChartTooltipContent'; +import { + ANNOTATION_LABEL_HEADROOM, + NEAREST_SERIES_MAX_DISTANCE_PX, + SINGLE_POINT_BAR_RIGHT_PADDING, + SINGLE_POINT_BAR_WIDTH_RATIO, + Y_AXIS_WIDTH, +} from './constants'; +import { useChartScales } from './useChartScales'; + +import styles from '@styles/HDXLineChart.module.scss'; + +// Debounce (ms) for the chart's ResponsiveContainer resize observer. Without +// it the observer fires on every frame, and a resize → re-render → resize +// cycle can keep the chart (and the form controls around it in the tile +// editor) from ever settling. +const RESPONSIVE_CONTAINER_DEBOUNCE_MS = 50; + +/** + * Compute the unique set of hexes referenced by `` defs + * inside MemoChart. Exported so a unit test can pin the dedup-and-union + * behavior without standing up a full recharts render (which jsdom + * struggles with at the container-sized SVG layer). + * + * Includes every categorical hex up front so any positional `` + * fill resolves, then unions in semantic hexes returned by the + * `getChartColor{Info,Success,Warning,Error}` helpers; those land in + * `lineData[].color` and would otherwise be missing a matching def. + * `undefined` colors are filtered so `c.replace('#', '')` can't throw + * on a future caller that leaves a series color unset. + */ +export function collectMemoChartGradientHexes( + lineData: { color?: string }[], +): string[] { + return Array.from( + new Set([ + ...COLORS, + ...lineData + .map(ld => ld.color) + .filter((c): c is string => typeof c === 'string'), + ]), + ); +} + +export const MemoChart = memo(function MemoChart({ + graphResults, + setIsClickActive, + refreshClickActive, + isClickActive, + dateRange, + lineData, + referenceLines, + annotations, + logReferenceTimestamp, + displayType = DisplayType.Line, + axisNumberFormat, + fallbackNumberFormat, + tooltipNumberFormatsByKey, + isLoading, + timestampKey = 'ts_bucket', + onTimeRangeSelect, + showLegend = true, + previousPeriodOffsetSeconds, + selectedSeriesNames, + onToggleSeries, + onClearSeriesSelection, + granularity, + dateRangeEndInclusive = true, + fitYAxisToData = false, +}: { + // Matches what useChartScales narrows to, so the hook's stricter type is + // actually checked at this boundary rather than satisfied by `any`. + graphResults: Record[]; + setIsClickActive: (v: ActiveClickPayload | undefined) => void; + /** + * In-place refresh of the open pin's frozen snapshot (rows only), without the + * cross-chart pin-dismiss broadcast setIsClickActive performs. Used by the + * resync effect. Falls back to setIsClickActive when not provided. + */ + refreshClickActive?: (v: ActiveClickPayload | undefined) => void; + isClickActive: ActiveClickPayload | undefined; + dateRange: [Date, Date] | Readonly<[Date, Date]>; + lineData: LineData[]; + referenceLines?: React.ReactNode; + /** + * Event markers (alerts, releases, …) drawn as dashed vertical lines with a + * label above. Passed as data rather than pre-rendered elements so the chart + * can clamp them to its own x-axis domain. Distinct from `referenceLines` + * (threshold lines). + */ + annotations?: ChartAnnotation[]; + displayType?: DisplayType; + axisNumberFormat?: NumberFormat; + fallbackNumberFormat?: NumberFormat; + tooltipNumberFormatsByKey: Map; + logReferenceTimestamp?: number; + isLoading?: boolean; + timestampKey?: string; + onTimeRangeSelect?: (start: Date, end: Date) => void; + showLegend?: boolean; + previousPeriodOffsetSeconds?: number; + selectedSeriesNames?: Set; + onToggleSeries?: (seriesName: string, isShiftKey?: boolean) => void; + /** Clear the current series isolation; renders a "Show All Series" button when set. */ + onClearSeriesSelection?: () => void; + granularity: string; + dateRangeEndInclusive?: boolean; + /** + * When true, the y-axis lower bound is the minimum of the displayed data + * (with padding) instead of zero. + **/ + fitYAxisToData?: boolean; +}) { + const _id = useId(); + const id = _id.replace(/:/g, ''); + + // recharts sync group, scoped via context (see chartSync). + const syncId = useChartSyncId(); + + const [isHovered, setIsHovered] = useState(false); + + // Filled by each Area's active dot with the series' active-point pixel Y, + // keyed by dataKey, so the tooltip can bold the series nearest the cursor. + // Read during the same render that draws the active dots. + const activePointYByKeyRef = useRef>(new Map()); + + // Stable writer passed to the active-dot element instead of the ref itself. + // Recharts freezes the props of graphical-item elements in its Immer store; + // passing a callback (rather than the Map) keeps the mutation on the + // closed-over ref, which is never frozen. + const captureActivePointY = useCallback((dataKey: string, cy: number) => { + activePointYByKeyRef.current.set(dataKey, cy); + }, []); + + // Key of the series whose line is nearest the cursor, lifted into state so + // the chart can emphasize that line (thicker stroke) and fade the rest. + // Set from the chart's mouse-move using the pixel Y the active dots captured + // on the prior frame; the one-frame lag is imperceptible and settles as soon + // as the pointer stops. The tooltip derives the same nearest row itself, + // same-frame, for its own bolding and dimming. + const [nearestSeriesKey, setNearestSeriesKey] = useState< + string | undefined + >(); + + const ChartComponent = useMemo( + () => (displayType === DisplayType.StackedBar ? BarChart : AreaChart), // LineChart; + [displayType], + ); + + const visibleLineData = useMemo( + () => getVisibleLineData(lineData, selectedSeriesNames), + [lineData, selectedSeriesNames], + ); + + // Series for the pinned tooltip's drill-down list: selection applied but NOT + // clamped to HARD_LINES_LIMIT, so "load all series" reveals series that were + // materialized (up to the render cap) yet not drawn. Kept separate from + // visibleLineData so the drawn chart stays bounded at HARD_LINES_LIMIT. + const tooltipLineData = useMemo( + () => getSelectedLineData(lineData, selectedSeriesNames), + [lineData, selectedSeriesNames], + ); + + const lines = useMemo(() => { + return visibleLineData.map(ld => { + const key = ld.dataKey; + const color = ld.color; + const strokeDasharray = ld.isDashed ? '4 3' : '0'; + const seriesName = getSeriesDisplayName(ld); + + return displayType === 'stacked_bar' ? ( + } + /> + ) : ( + on hover. + className={seriesClassName(id, key)} + activeDot={} + // Fill is always the gradient. Hiding it on hover is a CSS class + // toggle (styles.chartHovered), not a prop swap — swapping it here + // re-created every on each hover enter/leave (hover churn). + fill={`url(#time-chart-lin-grad-${id}-${color?.replace('#', '').toLowerCase()})`} + strokeDasharray={strokeDasharray} + name={seriesName} + isAnimationActive={false} + connectNulls + /> + ); + }); + }, [visibleLineData, displayType, id, captureActivePointY]); + + // Nearest-cursor emphasis (thicken the nearest line, fade the rest) applied + // via a tiny scoped + ); + }, [nearestSeriesKey, visibleLineData.length, id]); + + const [containerWidth, setContainerWidth] = useState(0); + + // Axis domains and annotation elements — see useChartScales. + const { yAxisDomain, xAxisDomain, annotationElements, laidOutAnnotations } = + useChartScales({ + annotations, + containerWidth, + dateRange, + granularity, + dateRangeEndInclusive, + displayType, + fitYAxisToData, + graphResults, + lineData, + selectedSeriesNames, + }); + + // The chart's outer positioned container. Used to convert a pointer's + // viewport clientX into a stable container-relative X for measuring + // drag-to-zoom distance — a single origin that is always defined, unlike the + // chart's activeCoordinate (null off a data point) or a child SVG element's + // offsetX (relative to whichever bar/path is under the pointer). + const containerRef = useRef(null); + const getContainerX = useCallback((e?: { clientX?: number } | null) => { + if (e?.clientX == null || containerRef.current == null) return undefined; + return e.clientX - containerRef.current.getBoundingClientRect().left; + }, []); + + // Build the pinned-tooltip payload for the clicked bucket from a recharts + // chart event `state`, including the viewport coords Mantine anchors to. + const buildActivePayloadFromState = useCallback( + (state?: { + activeCoordinate?: { x?: number; y?: number }; + activeLabel?: string | number; + }): ActiveClickPayload | undefined => { + const chartX = state?.activeCoordinate?.x; + const chartY = state?.activeCoordinate?.y; + const activeLabel = getActiveLabel(state); + if (chartX == null || chartY == null || activeLabel == null) { + return undefined; + } + const activeRow = graphResults.find( + row => String(row[timestampKey]) === activeLabel, + ); + // Build from tooltipLineData (uncapped), not visibleLineData: the pinned + // drill-down list may show more series than are drawn. + const activePayload = buildActiveClickSeries(tooltipLineData, activeRow); + if (activePayload.length === 0) { + return undefined; + } + const containerRect = containerRef.current?.getBoundingClientRect(); + const anchor = toViewportPoint(containerRect, { x: chartX, y: chartY }); + return { + viewportX: anchor.x, + viewportY: anchor.y, + activeLabel, + activePayload, + }; + }, + [graphResults, timestampKey, tooltipLineData], + ); + + // Keep the pinned tooltip's frozen snapshot in sync with its series set. + // The snapshot's rows are captured once at click time from `tooltipLineData`; + // when that set changes underneath an open pin — most notably after "load all + // series" materializes the previously-capped series — the frozen rows (and + // the "+N more" overflow derived from them) would otherwise stay stale, so + // clicking "load all" would leave a phantom "+N more" and never surface the + // newly-loaded rows. Rebuild the rows for the same clicked bucket from the + // current data while preserving the click-time anchor coords. + useEffect(() => { + if (isClickActive == null) return; + const activeRow = graphResults.find( + row => String(row[timestampKey]) === isClickActive.activeLabel, + ); + const nextPayload = buildActiveClickSeries(tooltipLineData, activeRow); + // No numeric value at the pinned bucket anymore (e.g. the series vanished); + // leave the existing snapshot rather than dismissing a still-anchored pin. + if (nextPayload.length === 0) return; + // Only update when the series set actually changed, so ordinary re-renders + // (hover, live-range ticks) don't churn state or reset scroll position. + if (sameActiveClickSeries(isClickActive.activePayload, nextPayload)) return; + // In-place refresh (no cross-chart pin-dismiss broadcast); fall back to + // setIsClickActive when the refresh callback isn't wired. + (refreshClickActive ?? setIsClickActive)({ + ...isClickActive, + activePayload: nextPayload, + }); + }, [ + isClickActive, + graphResults, + timestampKey, + tooltipLineData, + refreshClickActive, + setIsClickActive, + ]); + + // Recharts computes bar width from the smallest gap between ticks on a + // numerical XAxis. With a single data point there are no gaps, so the + // computed width is 0 and bars become invisible. Provide an explicit + // barSize for that case, sized to most of the drawable width (the + // xAxisDomain already spans exactly one granularity for StackedBar charts). + const singlePointBarSize = useMemo(() => { + if (displayType !== DisplayType.StackedBar) return undefined; + if (graphResults.length !== 1) return undefined; + const drawableWidth = Math.max( + 0, + containerWidth - Y_AXIS_WIDTH - SINGLE_POINT_BAR_RIGHT_PADDING, + ); + if (drawableWidth <= 0) return undefined; + return Math.max( + 1, + Math.floor(drawableWidth * SINGLE_POINT_BAR_WIDTH_RATIO), + ); + }, [displayType, graphResults.length, containerWidth]); + + const formatTime = useFormatTime(); + const xTickFormatter = useCallback( + (value: number, index: number) => { + return formatTime(value * 1000, { + format: index === 0 ? 'normal' : 'time', + }); + }, + [formatTime], + ); + + const tickFormatter = useCallback( + (value: number) => formatAxisTick(value, axisNumberFormat), + [axisNumberFormat], + ); + + const [highlightStart, setHighlightStart] = useState(); + const [highlightEnd, setHighlightEnd] = useState(); + const mouseDownPosRef = useRef(null); + + // Tracks the time range that was displayed before the user brushed to zoom + // in, so a "Reset zoom" control can restore it (mirrors Highcharts). It holds + // the earliest pre-zoom range across consecutive zoom-ins so resetting jumps + // all the way back to where zooming started. + const [zoomOrigin, setZoomOrigin] = useState<[Date, Date] | null>(null); + // Set right before we trigger our own brush-zoom so the dateRange effect can + // tell an internal zoom apart from an external time-range change. + const justZoomedRef = useRef(false); + // Set on a completed brush-zoom so the synthetic click that follows mouseup + // is swallowed (instead of freezing a stale drill-down tooltip). Kept + // separate from justZoomedRef and consumed/cleared by onClick, because the + // dateRange effect may never run when the post-zoom range is value-equal. + const suppressNextClickRef = useRef(false); + const prevDateRangeRef = useRef<[number, number] | null>(null); + + // Clear the reset-zoom affordance whenever the time range changes for a + // reason other than our own brush-zoom (e.g. the time picker or live tail), + // so the button never restores a stale range. Compared by value because + // `dateRange` can be a fresh array reference even when unchanged. + useEffect(() => { + const from = dateRange[0].getTime(); + const to = dateRange[1].getTime(); + const prev = prevDateRangeRef.current; + const changed = prev == null || prev[0] !== from || prev[1] !== to; + prevDateRangeRef.current = [from, to]; + + // A brush-zoom sets justZoomedRef; consume it here so the range change it + // caused doesn't clear zoomOrigin. Clear it even when the range didn't + // actually change (a value-equal zoom), so it can't leak into a later + // unrelated range change and wrongly preserve a stale zoomOrigin. + const wasInternalZoom = justZoomedRef.current; + justZoomedRef.current = false; + + if (!changed) { + return; + } + if (wasInternalZoom) { + return; + } + setZoomOrigin(null); + }, [dateRange]); + + const handleResetZoom = useCallback(() => { + if (zoomOrigin == null) { + return; + } + const [start, end] = zoomOrigin; + setZoomOrigin(null); + onTimeRangeSelect?.(new Date(start.getTime()), new Date(end.getTime())); + }, [zoomOrigin, onTimeRangeSelect]); + + const lineDataMap = useMemo(() => { + const map: { [key: string]: LineData } = {}; + lineData.forEach(ld => { + map[ld.dataKey] = ld; + }); + return map; + }, [lineData]); + + // Memoize the tooltip `content` element: recharts re-evaluates it every hover + // frame, so a fresh element each render defeats HDXLineChartTooltip's memo. + // Refs are stable, so only the listed values are deps. + const hoverTooltipContent = useMemo( + () => ( + + ), + [ + fallbackNumberFormat, + tooltipNumberFormatsByKey, + lineDataMap, + previousPeriodOffsetSeconds, + ], + ); + + // Latest values the mouse handlers read, in a ref so the handlers below can + // be stable useCallbacks. Recharts re-runs its event wiring when a handler + // prop's identity changes, so a stable reference avoids that per-render churn. + const handlerStateRef = useRef({ + isClickActive, + highlightStart, + highlightEnd, + dateRange, + onTimeRangeSelect, + }); + // Updated in an effect (not during render); the one-commit lag is harmless + // since these are only read in event handlers, which fire after commit. + useEffect(() => { + handlerStateRef.current = { + isClickActive, + highlightStart, + highlightEnd, + dateRange, + onTimeRangeSelect, + }; + }, [ + isClickActive, + highlightStart, + highlightEnd, + dateRange, + onTimeRangeSelect, + ]); + + const handleMouseEnter = useCallback(() => setIsHovered(true), []); + + const handleMouseLeave = useCallback(() => { + setIsHovered(false); + setNearestSeriesKey(undefined); + setHighlightStart(undefined); + setHighlightEnd(undefined); + mouseDownPosRef.current = null; + }, []); + + const handleMouseDown = useCallback( + (state: ChartMouseState, e?: { nativeEvent?: { clientX?: number } }) => { + // Record the drag start: the active bucket label and a container-relative + // pointer X (always defined, single origin) for measuring drag distance. + const chartX = getContainerX(e?.nativeEvent); + const downLabel = getActiveLabel(state); + if (downLabel != null && chartX != null) { + setHighlightStart(downLabel); + mouseDownPosRef.current = chartX; + } + }, + [getContainerX], + ); + + const handleMouseMove = useCallback( + (state: ChartMouseState) => { + setIsHovered(true); + + const { isClickActive, highlightStart } = handlerStateRef.current; + + // Track which series' line is nearest the cursor so the lines can + // emphasize it. The active dots captured their pixel Y on the prior frame; + // comparing the pointer's chartY picks the nearest line. Skip while a + // click-frozen tooltip is shown, matching the tooltip, and only set state + // when the key changes to keep re-renders rare. + const chartY = state?.activeCoordinate?.y; + const activePointYByKey = activePointYByKeyRef.current; + const nextNearest = + isClickActive == null && activePointYByKey.size > 1 && chartY != null + ? findNearestSeriesKey( + activePointYByKey, + Array.from(activePointYByKey.keys()), + chartY, + NEAREST_SERIES_MAX_DISTANCE_PX, + ) + : undefined; + setNearestSeriesKey(prev => (prev === nextNearest ? prev : nextNearest)); + + const moveLabel = getActiveLabel(state); + if (highlightStart != null && moveLabel != null) { + setHighlightEnd(moveLabel); + setIsClickActive(undefined); // Clear out any click state as we're highlighting + } + }, + [setIsClickActive], + ); + + const handleMouseUp = useCallback( + (state: ChartMouseState, e?: { nativeEvent?: { clientX?: number } }) => { + const MIN_DRAG_DISTANCE = 20; // Minimum horizontal drag distance in pixels + let dragDistance = 0; + + const { highlightStart, highlightEnd, dateRange, onTimeRangeSelect } = + handlerStateRef.current; + + // Measure against the same container-relative origin recorded on mouse + // down so the distance is never skewed or dropped when the pointer maps + // to no data point. + const chartX = getContainerX(e?.nativeEvent); + if (mouseDownPosRef.current != null && chartX != null) { + dragDistance = Math.abs(chartX - mouseDownPosRef.current); + } + + const activeLabel = getActiveLabel(state); + if (activeLabel != null && highlightStart === activeLabel) { + // If it's just a click, don't zoom + setHighlightStart(undefined); + setHighlightEnd(undefined); + mouseDownPosRef.current = null; + } else if ( + highlightStart != null && + highlightEnd != null && + dragDistance >= MIN_DRAG_DISTANCE + ) { + try { + // Remember the range we're zooming away from so "Reset zoom" can + // restore it. Keep the earliest origin across consecutive zooms. + const originStart = dateRange[0]; + const originEnd = dateRange[1]; + setZoomOrigin(prev => prev ?? [originStart, originEnd]); + // The synthetic click after this drag must be swallowed regardless of + // whether a range change follows; onClick consumes and clears this. + suppressNextClickRef.current = true; + // Only tell the [dateRange] effect to preserve zoomOrigin when a + // range change will actually happen; without onTimeRangeSelect the + // range never changes and the effect never runs. + if (onTimeRangeSelect != null) { + justZoomedRef.current = true; + } + // Order the range numerically — the labels are epoch-second strings, + // so a lexicographic compare would misorder values of differing + // digit length. + const startSec = Number(highlightStart); + const endSec = Number(highlightEnd); + const lowSec = Math.min(startSec, endSec); + const highSec = Math.max(startSec, endSec); + onTimeRangeSelect?.( + new Date(lowSec * 1000), + new Date(highSec * 1000), + ); + } catch (err) { + console.error('failed to highlight range', err); + justZoomedRef.current = false; + setZoomOrigin(null); + } + setHighlightStart(undefined); + setHighlightEnd(undefined); + mouseDownPosRef.current = null; + } else { + // Drag was too short, clear the highlight + setHighlightStart(undefined); + setHighlightEnd(undefined); + mouseDownPosRef.current = null; + } + }, + [getContainerX], + ); + + const handleClick = useCallback( + (state: ChartMouseState, e: { stopPropagation: () => void }) => { + // A brush-to-zoom ends with a synthetic click; skip that one click so we + // don't freeze a drill-down tooltip with now-stale, pre-zoom data. + // Consume-and-clear the flag here so a value-equal zoom (which never + // triggers the dateRange effect) can't leave it stuck and suppress every + // later click. + if (suppressNextClickRef.current) { + suppressNextClickRef.current = false; + e.stopPropagation(); + return; + } + const { highlightStart } = handlerStateRef.current; + // Freeze a tooltip at the clicked point. The builder mirrors the series + // actually drawn (legend selection + HARD_LINES_LIMIT). + const clickPayload = + highlightStart == null ? buildActivePayloadFromState(state) : undefined; + if (clickPayload != null) { + setIsClickActive(clickPayload); + // Pinned replaces hover; drop line emphasis to match. + setNearestSeriesKey(undefined); + } else { + // We clicked on the chart but outside of a line + setIsClickActive(undefined); + } + + // TODO: Properly detect clicks outside of the fake tooltip + e.stopPropagation(); + }, + [buildActivePayloadFromState, setIsClickActive], + ); + + const [hoveredAnnotation, setHoveredAnnotation] = + useState(null); + + return ( +
's fill prop, so the chart's ~N Area + // elements are not re-created on every hover enter/leave. + // `rr-block` tells the HyperDX/rrweb session-replay recorder to capture + // this chart as a placeholder rather than serializing its (very large) + // SVG DOM on every mutation — the dominant session-replay cost on + // high-cardinality dashboards. + className={cx( + 'rr-block', + styles.chartRoot, + isHovered && styles.chartHovered, + )} + // Scopes nearestSeriesStyle to this chart instance. + data-chart-id={id} + style={{ position: 'relative', width: '100%', height: '100%' }} + > + {nearestSeriesStyle} + {hoveredAnnotation != null && ( + + )} + + { + const w = width ?? 1; + setContainerWidth(prev => (prev === w ? prev : w)); + }} + className={isLoading ? 'effect-pulse' : ''} + > + + + {/* Gradient defs cover every hex that any fill may reference. + `COLORS` (the unified categorical palette) is included up-front + as a baseline; semantic colors returned by the + `getChartColor{Info,Success,Warning,Error}` helpers can also + appear in `lineData[].color` (e.g. info-level log series + resolve to `--color-chart-info`, chart blue `#437eef`, on both + brands, which matches categorical slot 0). Union them here so the + referenced `url(#time-chart-lin-grad-…)` always exists. */} + {collectMemoChartGradientHexes(lineData).map(c => { + return ( + + + + + ); + })} + + {isHovered && ( + + )} + + + {lines} + {/* HOVER tooltip (also drives cross-chart shadow tooltips via syncId). + Hidden once a point is clicked, where the pinned tooltip takes over. + Portaled to body so HDXLineChartTooltip can self-position (see its + docblock) and escape the chart's bounds near an edge. */} + {isClickActive == null && ( + + )} + {referenceLines} + {annotationElements} + {laidOutAnnotations != null && ( + + } + /> + )} + {highlightStart && highlightEnd ? ( + + ) : null} + {showLegend && ( + + } + offset={-100} + /> + )} + {/** Needs to be at the bottom to prevent re-rendering */} + {isClickActive != null ? ( + // The x-axis is numeric (scale="time"); pass a number so the marker + // positions without relying on the axis coercing a string. + + ) : null} + {logReferenceTimestamp != null ? ( + + ) : null} + + +
+ ); +}); diff --git a/packages/app/src/HDXMultiSeriesTimeChart/TooltipItem.tsx b/packages/app/src/HDXMultiSeriesTimeChart/TooltipItem.tsx new file mode 100644 index 0000000000..3a527165cf --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/TooltipItem.tsx @@ -0,0 +1,46 @@ +import { memo } from 'react'; + +import { ChartTooltipItem } from '@/components/charts/ChartTooltip'; +import type { NumberFormat } from '@/types'; + +export type TooltipPayload = { + dataKey: string; + name: string; + value: number; + color?: string; + stroke?: string; + strokeWidth?: number; + strokeDasharray?: string; + opacity?: number; +}; + +export const TooltipItem = memo( + ({ + p, + previous, + numberFormat, + highlighted, + dimmed, + }: { + p: TooltipPayload; + previous?: TooltipPayload; + numberFormat?: NumberFormat; + highlighted?: boolean; + dimmed?: boolean; + }) => { + return ( + + ); + }, +); diff --git a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts b/packages/app/src/HDXMultiSeriesTimeChart/__tests__/HDXMultiSeriesTimeChart.test.ts similarity index 100% rename from packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts rename to packages/app/src/HDXMultiSeriesTimeChart/__tests__/HDXMultiSeriesTimeChart.test.ts diff --git a/packages/app/src/HDXMultiSeriesTimeChart/chartData.ts b/packages/app/src/HDXMultiSeriesTimeChart/chartData.ts new file mode 100644 index 0000000000..2c62321724 --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/chartData.ts @@ -0,0 +1,297 @@ +import { type LineData, MAX_TIME_CHART_SERIES } from '@/ChartUtils'; +import type { NumberFormat } from '@/types'; +import { formatNumber } from '@/utils'; + +export const HARD_LINES_LIMIT = MAX_TIME_CHART_SERIES; + +// Debounce (ms) for the chart's ResponsiveContainer resize observer. Without +// it the observer fires on every frame, and a resize → re-render → resize +// cycle can keep the chart (and the form controls around it in the tile + +/** One series entry in a tooltip's per-bucket payload (hover or click-frozen). */ +export type ActiveClickSeries = { + value?: number; + dataKey?: string; + name?: string; + /** Series color, matching the legend swatch. */ + color?: string; + /** Previous-period value at the same bucket, for the percent-change chip. */ + previousValue?: number; + /** Whether this series is a dashed previous-period line. */ + isPreviousPeriod?: boolean; + /** Result column the values came from, for per-column number formatting. */ + valueColumnName?: string; +}; + +/** + * State for the pinned (click-locked) tooltip. Produced by MemoChart's onClick + * and rendered by DBTimeChart via ChartSeriesTooltip. (Hover uses recharts' own + * ; recharts' is also kept for its synced cursor.) + */ +export type ActiveClickPayload = { + /** Active point in viewport coords; the Popover anchor. */ + viewportX: number; + viewportY: number; + activeLabel: string; + activePayload?: ActiveClickSeries[]; +}; + +/** Series label shown in the legend, tooltip, and line `name`. */ +export const getSeriesDisplayName = (ld: LineData) => + ld.displayName || ld.dataKey; + +/** + * Stable, CSS-safe class for a series' , unique per chart (`id`) and + * series (`dataKey`). Lets the nearest-cursor emphasis target one line via CSS + * without changing any prop (which would rebuild every line on hover). + */ +export const seriesClassName = (id: string, dataKey: string) => + `hdx-series-${id}-${dataKey.replace(/[^a-zA-Z0-9_-]/g, '_')}`; + +// The subset of recharts' loosely-typed chart mouse-event `state` we read. +export type ChartMouseState = { + activeLabel?: string | number; + activeCoordinate?: { x?: number; y?: number }; +}; + +/** Normalize a chart event's active label (number | string) to a string. */ +export const getActiveLabel = (state?: { + activeLabel?: string | number; +}): string | undefined => + state?.activeLabel != null ? String(state.activeLabel) : undefined; + +/** + * Build the per-series payload for a click-frozen tooltip from the data row at + * the clicked bucket. Only the visible series (legend selection + + * HARD_LINES_LIMIT) with a numeric value at that bucket are included, so the + * drill-down popover mirrors exactly what is drawn. Exported for unit testing. + */ +export function buildActiveClickSeries( + visibleLineData: LineData[], + activeRow: Record | undefined, +): ActiveClickSeries[] { + if (activeRow == null) return []; + return visibleLineData.flatMap(ld => { + const value = activeRow[ld.dataKey]; + // Exclude non-finite values (NaN/±Infinity) — e.g. a ratio chart's + // zero-denominator bucket yields NaN. The tooltip already drops these + // (ChartSeriesTooltip filters on Number.isFinite), and admitting them here + // would also break the sameActiveClickSeries equality guard (NaN !== NaN). + if (typeof value !== 'number' || !Number.isFinite(value)) return []; + const isPreviousPeriod = ld.previousPeriodKey === ld.dataKey; + // Pair each current-period series with its previous-period value for the + // percent-change chip. Only current-period rows carry a comparison. + const previousRaw = + !isPreviousPeriod && ld.previousPeriodKey + ? activeRow[ld.previousPeriodKey] + : undefined; + return [ + { + dataKey: ld.dataKey, + name: getSeriesDisplayName(ld), + value, + color: ld.color, + isPreviousPeriod, + valueColumnName: ld.valueColumnName, + previousValue: + typeof previousRaw === 'number' ? previousRaw : undefined, + }, + ]; + }); +} + +/** + * Shallow structural equality for two click-frozen payloads, used to decide + * whether an open pin's snapshot needs rebuilding. Compares the drawn set + * (length + per-row dataKey) and the value/previousValue at the pinned bucket; + * a change in any means the tooltip's rows or its "+N more" overflow would + * differ. Cheap and order-sensitive — `buildActiveClickSeries` derives both + * sides from the same `tooltipLineData` ordering, so positions stay aligned. + * + * Uses `Object.is` for the numeric fields so a `NaN` value compares equal to + * itself (a plain `!==` would report NaN-holding snapshots as perpetually + * changed and drive the resync effect into an infinite update loop). + */ +export function sameActiveClickSeries( + a: ActiveClickSeries[] | undefined, + b: ActiveClickSeries[], +): boolean { + if (a == null || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if ( + a[i].dataKey !== b[i].dataKey || + !Object.is(a[i].value, b[i].value) || + !Object.is(a[i].previousValue, b[i].previousValue) + ) { + return false; + } + } + return true; +} + +/** + * Whether a series selection is active. The single source of truth for the + * "isolate to these series" predicate that gates line visibility, the y-axis + * domain, legend dimming, and the "Show All Series" control — so those can't + * drift out of sync. + */ +export function hasSeriesSelection( + selectedSeriesNames: Set | undefined, +): selectedSeriesNames is Set { + return !!selectedSeriesNames && selectedSeriesNames.size > 0; +} + +/** + * The series actually drawn on the chart. Without a selection, the first + * HARD_LINES_LIMIT of lineData. With a selection (legend isolate, checkbox + * filter, or table search), the selection is applied FIRST and then capped, so + * an explicitly chosen series always draws even if it ranks beyond the limit. + * Applying the cap first would slice out a chosen low-ranked series, leaving an + * empty chart while its stats still show in the legend table. The rendered + * lines and the drill-down click payload both derive from this same set so they + * never diverge. Exported for unit testing. + */ +export function getVisibleLineData( + lineData: LineData[], + selectedSeriesNames: Set | undefined, +): LineData[] { + return getSelectedLineData(lineData, selectedSeriesNames).slice( + 0, + HARD_LINES_LIMIT, + ); +} + +/** + * The series that survive the legend/table selection, WITHOUT the + * HARD_LINES_LIMIT draw cap. Same selection semantics as getVisibleLineData + * (selection applied first), so an explicitly chosen series is always kept. + * The pinned tooltip's rows derive from this so the "load all series" escape + * hatch can list series that were materialized but not drawn — the draw cap + * (getVisibleLineData) exists to keep the chart readable/fast, not to bound the + * scrollable drill-down list. Exported for unit testing. + */ +export function getSelectedLineData( + lineData: LineData[], + selectedSeriesNames: Set | undefined, +): LineData[] { + const hasSelection = hasSeriesSelection(selectedSeriesNames); + if (hasSelection) { + return lineData.filter(ld => + selectedSeriesNames.has(getSeriesDisplayName(ld)), + ); + } + return lineData; +} + +/** + * The series-tooltip rows to render (hover or pinned). `rows` must be sorted by + * value descending. Keeps the top `limit`; if the cursor-nearest series ranks + * past it, that series replaces the lowest kept row so it still shows (pass + * `undefined` for the pinned tooltip, which has no cursor). `hiddenCount` + * drives the "+N more" line. Exported for unit testing. + */ +export function getVisibleTooltipRows( + rows: T[], + nearestSeriesKey: string | undefined, + limit: number, +): { rows: T[]; hiddenCount: number } { + if (rows.length <= limit) { + return { rows, hiddenCount: 0 }; + } + const visible = rows.slice(0, limit); + if ( + nearestSeriesKey != null && + !visible.some(r => r.dataKey === nearestSeriesKey) + ) { + const nearest = rows.find(r => r.dataKey === nearestSeriesKey); + if (nearest != null) { + visible[visible.length - 1] = nearest; + } + } + return { rows: visible, hiddenCount: rows.length - visible.length }; +} + +/** + * A tick under MAGNITUDE_THRESHOLD gets at most this many decimal places, + * regardless of a chart's configured Decimals (which can go up to 10 - see + * NumberFormat.tsx). A tick >= MAGNITUDE_THRESHOLD is always an integer (see + * formatAxisTick) and so isn't governed by this - only a label under the + * threshold, with its own decimals, can make it wide. + * + * `` leaves a few dozen px for the label itself + * after Recharts' own tickSize + tickMargin. Measured in Chrome at 11px IBM + * Plex Mono (the tick font, monospace): every character costs ~6.6px, and + * 5 characters is the most that fits. At 2 decimals, "9.99" (4 chars) fits + * with room to spare, and adding a single extra character - a negative + * sign ("-9.99") or a percent suffix ("9.99%") - still exactly fits at 5. + * A 2-digit integer part pushes either of those over (6 chars, e.g. + * "-99.99"/"99.99%" both clip), which is why MAGNITUDE_THRESHOLD is 10, not + * 100: it trades a wider decimal-preserving range for values that are both + * negative and percent-formatted (out of scope here) for one that's safe + * for plain positive numbers, percent, and negative numbers each on their + * own - the only combination this codebase's charts have needed decimals + * for so far. 2 decimals is also enough to keep any value >= 0.005 + * distinguishable from 0, the failure this cap exists to fix. + */ +const MAX_AXIS_MANTISSA = 2; + +/** See MAX_AXIS_MANTISSA's comment for the width math behind this value. */ +const MAGNITUDE_THRESHOLD = 10; + +/** + * Y-axis tick label formatter. Exported so a unit test can pin the + * mantissa-precedence behavior without rendering recharts. + * + * `average` and `unit` are always forced (compact abbreviation like `1.2k` + * reads better on an axis than a series' configured unit repeated on every + * tick). For a tick at or past MAGNITUDE_THRESHOLD, mantissa is always 0 - + * large numbers stay `200`/`1k`/`256 MB`, never `200.00`/`1.23k`/`256.0 MB`, + * however many decimals the chart's Number Format configures. Below the + * threshold, an explicit axisNumberFormat.mantissa is honored (capped at + * MAX_AXIS_MANTISSA) rather than forced to 0. Without that, a chart whose + * configured Decimals produces correct tooltip/legend values (e.g. `0.14`) + * would still round every axis tick to `0` for any series whose values live + * under 1 (fractional Prometheus gauges, ratios, etc.). A tick of exactly 0 + * always short-circuits to an integer too - it's already unambiguous, and + * doesn't need the decimal rescue this formatter exists to provide. + * + * An explicit mantissa is the common case, not a rare one, which is why the + * large-magnitude branch can't just honor it: HyperDX's own bundled + * dashboard templates (go-runtime.json et al.) set mantissa on lines/byte/ + * percent tiles for tooltip readability, with values well above the + * near-zero problem this formatter fixes. + * + * `formatNumber` multiplies a percent-output value by 100 before applying + * mantissa (a percent tile's raw value is a 0-1 ratio, e.g. `0.25` for + * "25%"), so the magnitude check runs against that same displayed value, + * not the raw one - otherwise every percent tile would take the small- + * magnitude branch regardless of how large the rendered percentage is. + * + * This diverges from DBHeatmapChart's tickFormatter (magnitude-aware at a + * >= 1 threshold, but ignoring configured mantissa entirely for values + * under it) - a deliberate difference in both the threshold and whether + * configured mantissa is honored at all, not an oversight. + */ +export function formatAxisTick( + value: number, + axisNumberFormat?: NumberFormat, +): string { + if (!axisNumberFormat) { + return new Intl.NumberFormat('en-US', { + notation: 'compact', + compactDisplay: 'short', + }).format(value); + } + + const displayed = axisNumberFormat.output === 'percent' ? value * 100 : value; + + return formatNumber(value, { + ...axisNumberFormat, + mantissa: + displayed === 0 || Math.abs(displayed) >= MAGNITUDE_THRESHOLD + ? 0 + : Math.min(axisNumberFormat.mantissa ?? 0, MAX_AXIS_MANTISSA), + average: true, + unit: undefined, + }); +} diff --git a/packages/app/src/HDXMultiSeriesTimeChart/chartPrimitives.tsx b/packages/app/src/HDXMultiSeriesTimeChart/chartPrimitives.tsx new file mode 100644 index 0000000000..1e49260d93 --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/chartPrimitives.tsx @@ -0,0 +1,75 @@ +import type { BarProps } from 'recharts'; + +export const StackedBarWithOverlap = (props: BarProps) => { + const { x, y, width, fill } = props; + // `height` may arrive as a string, so coerce it to a number before the + // arithmetic below. + const height = + typeof props.height === 'number' ? props.height : Number(props.height ?? 0); + // Add a tiny bit to the height to create overlap. Otherwise there's a gap + return ( + 0 ? height + 0.5 : 0} + fill={fill} + /> + ); +}; + +type CaptureActiveDotProps = { + /** + * Called with each series' active-point pixel Y. This is a stable callback + * (not the ref itself) so Recharts, which stores this element's props in its + * Immer-backed store and freezes them, never freezes the underlying Map — + * the write happens on the ref captured in the callback's closure instead. + */ + onCapture: (dataKey: string, cy: number) => void; + cx?: number; + cy?: number; + dataKey?: string | number; + r?: number; + fill?: string; + stroke?: string; + strokeWidth?: number; +}; + +/** + * Active dot for an Area series. Records the active point's pixel Y (`cy`) + * via `onCapture`, keyed by dataKey, then draws the same dot Recharts + * renders by default. Recharts clones this element with the active-point + * props (cx, cy, dataKey, r, fill, stroke, strokeWidth) during the render + * that precedes the tooltip, so the capture is current when the tooltip reads + * it to find the series nearest the cursor. + */ +export function CaptureActiveDot({ + onCapture, + cx, + cy, + dataKey, + r, + fill, + stroke, + strokeWidth, +}: CaptureActiveDotProps) { + if (dataKey != null && typeof cy === 'number' && Number.isFinite(cy)) { + // Written synchronously during render so the tooltip, which Recharts + // renders after the graphical items in the same commit, reads the + // current frame's positions rather than the previous frame's. + onCapture(String(dataKey), cy); + } + if (typeof cx !== 'number' || typeof cy !== 'number') { + return null; + } + return ( + + ); +} diff --git a/packages/app/src/HDXMultiSeriesTimeChart/constants.ts b/packages/app/src/HDXMultiSeriesTimeChart/constants.ts new file mode 100644 index 0000000000..2c46559e33 --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/constants.ts @@ -0,0 +1,25 @@ +/** Layout and measurement constants shared across the chart's parts. */ +export const MAX_LEGEND_ITEMS = 4; + +// Max rows rendered in a series tooltip (hover and pinned). Each row mounts a +// DOM node (the pinned one also a Mantine Tooltip), so an uncapped busy bucket +// was a jank source; the rest collapse into a "+N more" line (see +// getVisibleTooltipRows). Exported so the pinned tooltip shares the cap. +export const MAX_TOOLTIP_ROWS = 20; + +// Vertical pixel distance within which a series' line counts as "near" the +// cursor for tooltip highlighting. Beyond this, no row is emphasized so the +// tooltip is not misleading when the pointer is in empty space. +export const NEAREST_SERIES_MAX_DISTANCE_PX = 30; + +// Gap below the data point for the hover tooltip. Kept equal to the pinned +// tooltip's Popover `offset` so both land in the same spot. +export const TOOLTIP_POINT_OFFSET_PX = 12; + +export const Y_AXIS_WIDTH = 40; +export const SINGLE_POINT_BAR_RIGHT_PADDING = 10; +export const SINGLE_POINT_BAR_WIDTH_RATIO = 0.8; +// Top margin (px) reserved above the plot for annotation labels ("Alert"/"OK"), +// added only when a chart is showing annotations so other charts keep their +// tighter default headroom. +export const ANNOTATION_LABEL_HEADROOM = 18; diff --git a/packages/app/src/HDXMultiSeriesTimeChart/index.ts b/packages/app/src/HDXMultiSeriesTimeChart/index.ts new file mode 100644 index 0000000000..59e3af04e7 --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/index.ts @@ -0,0 +1,20 @@ +/** + * Public surface of the multi-series time chart. Split out of a single + * 1500-line module; consumers (and the tests that + * `jest.mock('@/HDXMultiSeriesTimeChart')`) import from here, so the internal + * file layout stays free to change. + */ +export { + type ActiveClickPayload, + type ActiveClickSeries, + buildActiveClickSeries, + formatAxisTick, + getSelectedLineData, + getVisibleLineData, + getVisibleTooltipRows, + HARD_LINES_LIMIT, + sameActiveClickSeries, +} from './chartData'; +export { MAX_TOOLTIP_ROWS, TOOLTIP_POINT_OFFSET_PX } from './constants'; +export { collectMemoChartGradientHexes, MemoChart } from './MemoChart'; +export { TooltipItem } from './TooltipItem'; diff --git a/packages/app/src/HDXMultiSeriesTimeChart/useChartScales.ts b/packages/app/src/HDXMultiSeriesTimeChart/useChartScales.ts new file mode 100644 index 0000000000..c195233e2f --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/useChartScales.ts @@ -0,0 +1,184 @@ +import { useMemo } from 'react'; +import { add, isSameSecond, sub } from 'date-fns'; +import { AxisDomain } from 'recharts/types/util/types'; +import { convertGranularityToSeconds } from '@hyperdx/common-utils/dist/core/utils'; +import { DisplayType } from '@hyperdx/common-utils/dist/types'; + +import { + getSeriesColorForGroup, + type LineData, + toStartOfInterval, +} from '@/ChartUtils'; +import { + ChartAnnotation, + getAnnotationElements, + layoutAnnotations, + resolveAnnotationSeries, +} from '@/components/charts/chartAnnotations'; + +import { hasSeriesSelection } from './chartData'; +import { Y_AXIS_WIDTH } from './constants'; + +type UseChartScalesArgs = { + annotations: ChartAnnotation[] | undefined; + /** Measured container width; annotation layout needs the drawable width. */ + containerWidth: number; + dateRange: readonly [Date, Date]; + granularity: string; + dateRangeEndInclusive: boolean; + displayType: DisplayType; + fitYAxisToData: boolean | undefined; + graphResults: Record[]; + lineData: LineData[]; + selectedSeriesNames: Set | undefined; +}; + +/** + * Derive the chart's axis domains and the annotation elements that hang off the + * x-domain. + * + * Extracted from MemoChart because it is pure derivation from props — no state, + * no event handlers, no recharts tree — and the y-domain rules (fit-to-data, + * legend selection, zero-anchored bars) are easier to follow on their own. + */ +export function useChartScales({ + annotations, + containerWidth, + dateRange, + granularity, + dateRangeEndInclusive, + displayType, + fitYAxisToData, + graphResults, + lineData, + selectedSeriesNames, +}: UseChartScalesArgs) { + const yAxisDomain: AxisDomain = useMemo(() => { + const hasSelection = hasSeriesSelection(selectedSeriesNames); + + // Fitting the y-axis lower bound to the data only applies to line charts. + // Bar charts are always anchored at zero so the bar lengths stay + // proportional to their values. + const shouldFitYAxis = + fitYAxisToData && displayType !== DisplayType.StackedBar; + + // The domain follows the visible series only. With no selection and no fit, + // let Recharts auto-scale, which pins the lower bound to 0. + if (!hasSelection && !shouldFitYAxis) { + return [0, 'auto']; + } + + // Calculate domain based on visible series (all series when there's no + // explicit selection). + let minValue = Infinity; + let maxValue = -Infinity; + + graphResults.forEach(dataPoint => { + lineData.forEach(ld => { + const seriesName = ld.displayName || ld.dataKey; + // Only consider visible series + if (!hasSelection || selectedSeriesNames.has(seriesName)) { + const value = dataPoint[ld.dataKey]; + if (typeof value === 'number' && !isNaN(value)) { + minValue = Math.min(minValue, value); + maxValue = Math.max(maxValue, value); + } + } + }); + }); + + // If we found valid values, return them with some padding + if (minValue !== Infinity && maxValue !== -Infinity) { + const padding = (maxValue - minValue) * 0.05; // 5% padding + // When fitting to data, allow the lower bound to follow the data + // minimum; otherwise keep it pinned at zero. The 5% padding must not + // drag the axis below zero unless the data itself is negative, so + // clamp at zero whenever the minimum is non-negative. + const lowerBound = + shouldFitYAxis && minValue < 0 + ? minValue - padding + : Math.max(0, minValue - padding); + const upperBound = maxValue + padding; + return [lowerBound, upperBound]; + } + + return ['auto', 'auto']; + }, [ + graphResults, + lineData, + selectedSeriesNames, + fitYAxisToData, + displayType, + ]); + + // Typed as the tuple it actually returns rather than the wider AxisDomain, so + // the annotation elements below can read [min, max] without asserting. Still + // assignable to XAxis's `domain`. + const xAxisDomain: [number, number] = useMemo(() => { + let startTime = toStartOfInterval(dateRange[0], granularity); + let endTime = toStartOfInterval(dateRange[1], granularity); + const endTimeIsBoundaryAligned = isSameSecond(dateRange[1], endTime); + if (endTimeIsBoundaryAligned && !dateRangeEndInclusive) { + endTime = sub(endTime, { + seconds: convertGranularityToSeconds(granularity), + }); + } + + // For bar charts, extend the domain in both directions by half a granularity unit + // so that the full bar width is within the bounds of the chart + if (displayType === DisplayType.StackedBar) { + const halfGranularitySeconds = + convertGranularityToSeconds(granularity) / 2; + startTime = sub(startTime, { seconds: halfGranularitySeconds }); + endTime = add(endTime, { seconds: halfGranularitySeconds }); + } + + return [startTime.getTime() / 1000, endTime.getTime() / 1000]; + }, [dateRange, granularity, dateRangeEndInclusive, displayType]); + + // Tint each marker to match the series it describes and drop the ones that + // can't be tied to anything on this chart — see `resolveAnnotationSeries`. + const coloredAnnotations = useMemo(() => { + if (!annotations?.length) { + return annotations; + } + return resolveAnnotationSeries(annotations, group => + getSeriesColorForGroup(lineData, group), + ); + }, [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]); + + // Alert/event markers as dashed lines, clamped to the chart's x-axis domain so + // an edge marker (e.g. an alert already firing at window open) stays visible + // instead of being dropped. Labels float in the reserved top headroom. + const annotationElements = useMemo(() => { + if (!coloredAnnotations?.length) { + return null; + } + return getAnnotationElements(coloredAnnotations, { + domain: xAxisDomain, + // Drawable width, so markers too close together share one label. Zero on + // the first paint (before ResponsiveContainer measures), which the + // renderer treats as "label everything". + plotWidth: Math.max(0, containerWidth - Y_AXIS_WIDTH), + }); + }, [coloredAnnotations, xAxisDomain, containerWidth]); + + return { + yAxisDomain, + xAxisDomain, + annotationElements, + laidOutAnnotations, + }; +} diff --git a/packages/app/src/components/DBTimeChart/ChartTooltipOverlay.tsx b/packages/app/src/components/DBTimeChart/ChartTooltipOverlay.tsx new file mode 100644 index 0000000000..c50019f084 --- /dev/null +++ b/packages/app/src/components/DBTimeChart/ChartTooltipOverlay.tsx @@ -0,0 +1,164 @@ +import { useEffect, useRef } from 'react'; +import { NumberFormat } from '@hyperdx/common-utils/dist/types'; +import { Popover, Portal } from '@mantine/core'; + +import { ChartSeriesTooltip } from '@/components/charts/ChartSeriesTooltip'; +import { useChartTooltipZIndex } from '@/components/charts/ChartTooltip'; +import type { ActiveClickPayload } from '@/HDXMultiSeriesTimeChart'; + +// The interactive PINNED tooltip, rendered over the chart in a body-portaled +// Mantine Popover anchored at the clicked point. Hover uses the recharts tooltip +// in MemoChart instead; this is only for the click-locked state. +export function ChartTooltipOverlay({ + payload, + buildSearchUrl, + onDismiss, + onFocusSeries, + onShowAllSeries, + fallbackNumberFormat, + numberFormatByKey, + previousPeriodOffsetSeconds, + hiddenSeriesCount, + onLoadAllSeries, + expanded, +}: { + payload: ActiveClickPayload | undefined; + buildSearchUrl: (key?: string, value?: number) => string | null; + onDismiss: () => void; + /** Focus a series by its raw series key (dataKey) and display name. */ + onFocusSeries: (payload: { dataKey?: string; name: string }) => void; + /** Clear an active series focus; undefined when nothing is focused. */ + onShowAllSeries?: () => void; + fallbackNumberFormat?: NumberFormat; + /** Per-value-column formats, keyed by result column name. */ + numberFormatByKey: Map; + previousPeriodOffsetSeconds?: number; + /** Series dropped by the chart's render cap (see ChartSeriesTooltip). */ + hiddenSeriesCount?: number; + /** Render every series on the chart, bypassing the cap. */ + onLoadAllSeries?: () => void; + /** "Load all" is active: render every row in the scrollable tooltip body. */ + expanded?: boolean; +}) { + const isOpen = + payload != null && + payload.activePayload != null && + payload.activePayload.length > 0; + + const popoverZIndex = useChartTooltipZIndex({ pinned: true }); + + const dropdownRef = useRef(null); + + // The pinned tooltip anchors at `position: fixed` viewport coords captured + // once at click time. When a surrounding scroll container scrolls, the chart + // moves but the fixed tooltip stays glued to the viewport, detaching from its + // data point (Mantine's closeOnClickOutside/closeOnEscape don't fire on + // scroll). Dismiss on scroll instead so it never floats away — but ignore + // scrolls originating inside the tooltip's own scrollable series list, or a + // long tooltip couldn't be scrolled without instantly closing. + useEffect(() => { + if (!isOpen) return; + const handleScroll = (e: Event) => { + const target = e.target; + if (target instanceof Node && dropdownRef.current?.contains(target)) { + return; + } + onDismiss(); + }; + window.addEventListener('scroll', handleScroll, { + capture: true, + passive: true, + }); + return () => { + window.removeEventListener('scroll', handleScroll, { capture: true }); + }; + }, [isOpen, onDismiss]); + + // Dismiss on outside click. Mantine's closeOnClickOutside misses it because + // the chart's recharts onClick calls stopPropagation (see + // HDXMultiSeriesTimeChart handleClick); a capture-phase listener sees the + // click regardless, ignoring clicks inside the tooltip's own dropdown. + useEffect(() => { + if (!isOpen) return; + const handleMouseDown = (e: MouseEvent) => { + const target = e.target; + if (target instanceof Node && dropdownRef.current?.contains(target)) { + return; + } + onDismiss(); + }; + document.addEventListener('mousedown', handleMouseDown, true); + return () => { + document.removeEventListener('mousedown', handleMouseDown, true); + }; + }, [isOpen, onDismiss]); + + if (!isOpen) { + return null; + } + + return ( + // Portal to body so the `position: fixed` anchor resolves against the + // viewport: dashboard tiles use CSS transforms, and a transformed ancestor + // would otherwise make `fixed` resolve against it and throw the tooltip off. + + { + if (!opened) { + onDismiss(); + } + }} + closeOnClickOutside + closeOnEscape + trapFocus={false} + withinPortal + position="bottom" + offset={12} + middlewares={{ flip: true, shift: true }} + returnFocus={false} + zIndex={popoverZIndex} + > + + {/* 1x1 anchor at the clicked data point. */} +
+ + + + + + + ); +} diff --git a/packages/app/src/components/DBTimeChart.tsx b/packages/app/src/components/DBTimeChart/DBTimeChart.tsx similarity index 55% rename from packages/app/src/components/DBTimeChart.tsx rename to packages/app/src/components/DBTimeChart/DBTimeChart.tsx index 3a55147b09..491aa7f943 100644 --- a/packages/app/src/components/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart/DBTimeChart.tsx @@ -1,45 +1,34 @@ -import React, { - memo, - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from 'react'; -import { add, differenceInSeconds } from 'date-fns'; -import { - convertGranularityToSeconds, - getAlignedDateRange, -} from '@hyperdx/common-utils/dist/core/utils'; -import { - isBuilderChartConfig, - isPromqlChartConfig, - isRawSqlChartConfig, -} from '@hyperdx/common-utils/dist/guards'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { differenceInSeconds } from 'date-fns'; +import { getAlignedDateRange } from '@hyperdx/common-utils/dist/core/utils'; +import { isBuilderChartConfig } from '@hyperdx/common-utils/dist/guards'; import { BuilderChartConfigWithDateRange, ChartConfigWithDateRange, DisplayType, } from '@hyperdx/common-utils/dist/types'; -import { Popover, Portal } from '@mantine/core'; -import { IconChartBar, IconChartLine } from '@tabler/icons-react'; import api from '@/api'; import { - AGG_FNS, - buildEventsSearchUrl, - ChartKeyJoiner, convertToTimeChartConfig, formatResponseForTimeChart, getPreviousDateRange, shouldFillNullsWithZero, - tryExpandConfigVariables, useTimeChartSettings, } from '@/ChartUtils'; import { ChartAnnotation } from '@/components/charts/chartAnnotations'; -import { ChartSeriesTooltip } from '@/components/charts/ChartSeriesTooltip'; -import { useChartTooltipZIndex } from '@/components/charts/ChartTooltip'; +import ChartContainer from '@/components/charts/ChartContainer'; +import ChartErrorState, { + ChartErrorStateVariant, +} from '@/components/charts/ChartErrorState'; +import { ChartTooltipOverlay } from '@/components/DBTimeChart/ChartTooltipOverlay'; +import { useCrossChartPinDismiss } from '@/components/DBTimeChart/crossChartPin'; +import { + buildSeriesSearchUrl, + decodeSeriesGroupFilters, + type SeriesGroupFilter, +} from '@/components/DBTimeChart/searchUrl'; +import { useChartToolbarItems } from '@/components/DBTimeChart/useChartToolbarItems'; import { MAX_LOADABLE_TIME_CHART_SERIES, resolveRenderedSeriesCap, @@ -48,247 +37,6 @@ import { type ActiveClickPayload, MemoChart } from '@/HDXMultiSeriesTimeChart'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation'; import { useChartNumberFormats, useSource } from '@/source'; -import type { NumberFormat } from '@/types'; - -import ChartContainer from './charts/ChartContainer'; -import ChartErrorState, { - ChartErrorStateVariant, -} from './charts/ChartErrorState'; -import DateRangeIndicator from './charts/DateRangeIndicator'; -import DisplaySwitcher from './charts/DisplaySwitcher'; -import HiddenSeriesIndicator from './charts/HiddenSeriesIndicator'; -import MVOptimizationIndicator from './MaterializedViews/MVOptimizationIndicator'; - -/** A single group column / value pair decoded from a chart series key. */ -export type SeriesGroupFilter = { column: string; value: string }; - -// Only one pinned tooltip at a time across all charts. Module-level (not -// context) because charts can be scattered with no common provider, and their -// onClick stopPropagation hides cross-chart clicks from Mantine's click-outside. -const pinnedTooltipRegistry = new Map void>(); - -function broadcastTooltipPinned(activeId: string) { - pinnedTooltipRegistry.forEach((dismiss, id) => { - if (id !== activeId) { - dismiss(); - } - }); -} - -// Registers this chart's dismiss handler and returns a callback to close every -// other chart's pinned tooltip (call it when pinning this one). -function useCrossChartPinDismiss(onDismiss: () => void): () => void { - const id = useId(); - // Keep the latest onDismiss without re-subscribing each render. - const onDismissRef = useRef(onDismiss); - useEffect(() => { - onDismissRef.current = onDismiss; - }, [onDismiss]); - - useEffect(() => { - pinnedTooltipRegistry.set(id, () => onDismissRef.current()); - return () => { - pinnedTooltipRegistry.delete(id); - }; - }, [id]); - - return useCallback(() => broadcastTooltipPinned(id), [id]); -} - -// Decode a Recharts series key (e.g. "count · error · api") into the -// underlying group-column filters. This is the same decode `buildSearchUrl` -// uses to build a drill-down URL, extracted so the focus callback can hand the -// caller structured filters (rather than a display string) to apply to a -// sibling results list. -export function decodeSeriesGroupFilters({ - seriesKey, - groupColumns, - isSingleValueColumn, -}: { - seriesKey: string | undefined; - groupColumns: string[]; - isSingleValueColumn: boolean | undefined; -}): SeriesGroupFilter[] { - const seriesKeys = seriesKey?.split(ChartKeyJoiner); - const groupFilters: SeriesGroupFilter[] = []; - - if (seriesKeys?.length && groupColumns?.length) { - // When the series has multiple value columns, the key is prefixed with the - // value column name (e.g. "count · error"), so the group values start at - // index 1. (The "no group columns" case the original inline code also - // guarded is impossible here — this block only runs when groupColumns is - // non-empty.) - const startsWithValueColumn = !(isSingleValueColumn ?? true); - const groupValues = startsWithValueColumn - ? seriesKeys.slice(1) - : seriesKeys; - - groupValues.forEach((value, index) => { - if (groupColumns[index] != null) { - groupFilters.push({ column: groupColumns[index], value }); - } - }); - } - - return groupFilters; -} - -// The interactive PINNED tooltip, rendered over the chart in a body-portaled -// Mantine Popover anchored at the clicked point. Hover uses the recharts tooltip -// in MemoChart instead; this is only for the click-locked state. -function ChartTooltipOverlay({ - payload, - buildSearchUrl, - onDismiss, - onFocusSeries, - onShowAllSeries, - fallbackNumberFormat, - numberFormatByKey, - previousPeriodOffsetSeconds, - hiddenSeriesCount, - onLoadAllSeries, - expanded, -}: { - payload: ActiveClickPayload | undefined; - buildSearchUrl: (key?: string, value?: number) => string | null; - onDismiss: () => void; - /** Focus a series by its raw series key (dataKey) and display name. */ - onFocusSeries: (payload: { dataKey?: string; name: string }) => void; - /** Clear an active series focus; undefined when nothing is focused. */ - onShowAllSeries?: () => void; - fallbackNumberFormat?: NumberFormat; - /** Per-value-column formats, keyed by result column name. */ - numberFormatByKey: Map; - previousPeriodOffsetSeconds?: number; - /** Series dropped by the chart's render cap (see ChartSeriesTooltip). */ - hiddenSeriesCount?: number; - /** Render every series on the chart, bypassing the cap. */ - onLoadAllSeries?: () => void; - /** "Load all" is active: render every row in the scrollable tooltip body. */ - expanded?: boolean; -}) { - const isOpen = - payload != null && - payload.activePayload != null && - payload.activePayload.length > 0; - - const popoverZIndex = useChartTooltipZIndex({ pinned: true }); - - const dropdownRef = useRef(null); - - // The pinned tooltip anchors at `position: fixed` viewport coords captured - // once at click time. When a surrounding scroll container scrolls, the chart - // moves but the fixed tooltip stays glued to the viewport, detaching from its - // data point (Mantine's closeOnClickOutside/closeOnEscape don't fire on - // scroll). Dismiss on scroll instead so it never floats away — but ignore - // scrolls originating inside the tooltip's own scrollable series list, or a - // long tooltip couldn't be scrolled without instantly closing. - useEffect(() => { - if (!isOpen) return; - const handleScroll = (e: Event) => { - const target = e.target; - if (target instanceof Node && dropdownRef.current?.contains(target)) { - return; - } - onDismiss(); - }; - window.addEventListener('scroll', handleScroll, { - capture: true, - passive: true, - }); - return () => { - window.removeEventListener('scroll', handleScroll, { capture: true }); - }; - }, [isOpen, onDismiss]); - - // Dismiss on outside click. Mantine's closeOnClickOutside misses it because - // the chart's recharts onClick calls stopPropagation (see - // HDXMultiSeriesTimeChart handleClick); a capture-phase listener sees the - // click regardless, ignoring clicks inside the tooltip's own dropdown. - useEffect(() => { - if (!isOpen) return; - const handleMouseDown = (e: MouseEvent) => { - const target = e.target; - if (target instanceof Node && dropdownRef.current?.contains(target)) { - return; - } - onDismiss(); - }; - document.addEventListener('mousedown', handleMouseDown, true); - return () => { - document.removeEventListener('mousedown', handleMouseDown, true); - }; - }, [isOpen, onDismiss]); - - if (!isOpen) { - return null; - } - - return ( - // Portal to body so the `position: fixed` anchor resolves against the - // viewport: dashboard tiles use CSS transforms, and a transformed ancestor - // would otherwise make `fixed` resolve against it and throw the tooltip off. - - { - if (!opened) { - onDismiss(); - } - }} - closeOnClickOutside - closeOnEscape - trapFocus={false} - withinPortal - position="bottom" - offset={12} - middlewares={{ flip: true, shift: true }} - returnFocus={false} - zIndex={popoverZIndex} - > - - {/* 1x1 anchor at the clicked data point. */} -
- - - - - - - ); -} type DBTimeChartComponentProps = { config: ChartConfigWithDateRange; @@ -654,7 +402,9 @@ function DBTimeChartComponent({ ActiveClickPayload | undefined >(undefined); - const dismissPinned = useCallback(() => setActiveClickPayload(undefined), []); + const dismissPinned = useCallback(() => { + setActiveClickPayload(undefined); + }, []); const notifyTooltipPinned = useCrossChartPinDismiss(dismissPinned); // Dismiss any open pin when the query shape changes: its frozen snapshot @@ -708,128 +458,18 @@ function DBTimeChartComponent({ }, [activeClickPayload]); const buildSearchUrl = useCallback( - (seriesKey?: string, seriesValue?: number) => { - // Raw SQL charts are not supported for drill-down as we don't know the source which is being used. - if ( - clickedActiveLabelDate == null || - source == null || - isRawSqlChartConfig(config) || - isPromqlChartConfig(config) - ) { - return null; - } - - // The search page has no variable machinery, so the expressions read here - // and handed to buildEventsSearchUrl must be final SQL/Lucene. - // `whereLanguage` is pinned to buildEventsSearchUrl's default first, since - // expansion below leaves nothing for it to expand. - const expandedConfig = tryExpandConfigVariables({ - ...config, - whereLanguage: config.whereLanguage || 'lucene', - }); - - // Parse the series key to extract group values - const seriesKeys = seriesKey?.split(ChartKeyJoiner); - const groupFilters = decodeSeriesGroupFilters({ + (seriesKey?: string, seriesValue?: number) => + buildSeriesSearchUrl({ seriesKey, + seriesValue, + clickedActiveLabelDate, + source, + config, + granularity, groupColumns, + valueColumns, isSingleValueColumn, - }); - - // Build value range filter for Y-axis if provided - let valueRangeFilter: - | { - expression: string; - value: number; - } - | undefined; - - // Metric formula configs with hidden operand series project only the - // formula column(s), so value columns no longer map positionally onto - // `select` — skip the value-range filter rather than misattributing a - // formula value to an operand's expression. (With operands shown, the - // operand columns still map by index and formula columns fall past the - // `< expandedConfig.select.length` bound below.) - const operandsHidden = - isBuilderChartConfig(expandedConfig) && - (expandedConfig.formulas?.length ?? 0) > 0 && - expandedConfig.showOperandSeries === false; - - if ( - seriesValue && - !operandsHidden && - Array.isArray(expandedConfig.select) && - expandedConfig.select.length > 0 - ) { - // Determine which value column to filter on - let valueExpression: string | undefined; - - if ( - (isSingleValueColumn ?? true) && - expandedConfig.select.length === 1 - ) { - const firstSelect = expandedConfig.select[0]; - const aggFn = - typeof firstSelect === 'string' ? undefined : firstSelect.aggFn; - // Only add value range filter if the aggregation is attributable - const isAttributable = - AGG_FNS.find(fn => fn.value === aggFn)?.isAttributable !== false; - - if (isAttributable) { - valueExpression = - typeof firstSelect === 'string' - ? firstSelect - : firstSelect.valueExpression; - } - } else if (seriesKeys?.length && (valueColumns?.length ?? 0) > 0) { - const firstPart = seriesKeys[0]; - const valueColumnIndex = valueColumns?.findIndex( - col => col === firstPart, - ); - - if ( - valueColumnIndex != null && - valueColumnIndex >= 0 && - valueColumnIndex < expandedConfig.select.length - ) { - const selectItem = expandedConfig.select[valueColumnIndex]; - const aggFn = - typeof selectItem === 'string' ? undefined : selectItem.aggFn; - // Only add value range filter if the aggregation is attributable - const isAttributable = - AGG_FNS.find(fn => fn.value === aggFn)?.isAttributable !== false; - - if (isAttributable) { - valueExpression = - typeof selectItem === 'string' - ? selectItem - : selectItem.valueExpression; - } - } - } - - if (valueExpression) { - valueRangeFilter = { - expression: valueExpression, - value: seriesValue, - }; - } - } - - // Calculate time range from clicked date and granularity - const from = clickedActiveLabelDate; - const to = add(clickedActiveLabelDate, { - seconds: convertGranularityToSeconds(granularity), - }); - - return buildEventsSearchUrl({ - source, - config: expandedConfig, - dateRange: [from, to], - groupFilters, - valueRangeFilter, - }); - }, + }), [ clickedActiveLabelDate, config, @@ -864,108 +504,23 @@ function DBTimeChartComponent({ [onFocusSeries, groupColumns, isSingleValueColumn, handleToggleSeries], ); - const toolbarItemsMemo = useMemo(() => { - const allToolbarItems = []; - - if (toolbarPrefix && toolbarPrefix.length > 0) { - allToolbarItems.push(...toolbarPrefix); - } - - if (source && showMVOptimizationIndicator && builderQueriedConfig) { - allToolbarItems.push( - , - ); - } - - const mvDateRange = mvOptimizationData?.optimizedConfig?.dateRange; - const isAlignedToChartGranularity = - queriedConfig.alignDateRangeToGranularity !== false; - - if ( - showDateRangeIndicator && - (mvDateRange || isAlignedToChartGranularity) - ) { - const mvGranularity = isAlignedToChartGranularity - ? undefined - : mvOptimizationData?.explanations.find(e => e.success)?.mvConfig - .minGranularity; - - allToolbarItems.push( - , - ); - } - - if (showDisplaySwitcher) { - allToolbarItems.push( - , - }, - { - value: DisplayType.StackedBar, - label: config.compareToPreviousPeriod - ? 'Bar Chart Unavailable When Comparing to Previous Period' - : 'Display as Bar Chart', - icon: , - disabled: config.compareToPreviousPeriod, - }, - ]} - />, - ); - } - - if (hiddenSeriesCount > 0) { - allToolbarItems.push( - , - ); - } - - if (toolbarSuffix && toolbarSuffix.length > 0) { - allToolbarItems.push(...toolbarSuffix); - } - - return allToolbarItems; - }, [ + const toolbarItemsMemo = useChartToolbarItems({ builderQueriedConfig, config, displayType, handleSetDisplayType, + hiddenSeriesCount, + loadAllHandler, + mvOptimizationData, + queriedConfig, + renderedSeriesCount, + showDateRangeIndicator, showDisplaySwitcher, + showMVOptimizationIndicator, source, toolbarPrefix, toolbarSuffix, - showMVOptimizationIndicator, - showDateRangeIndicator, - mvOptimizationData, - queriedConfig, - hiddenSeriesCount, - renderedSeriesCount, - loadAllHandler, - ]); + }); return ( diff --git a/packages/app/src/components/__tests__/DBTimeChart.test.tsx b/packages/app/src/components/DBTimeChart/__tests__/DBTimeChart.test.tsx similarity index 99% rename from packages/app/src/components/__tests__/DBTimeChart.test.tsx rename to packages/app/src/components/DBTimeChart/__tests__/DBTimeChart.test.tsx index 380e832757..b32352f3b6 100644 --- a/packages/app/src/components/__tests__/DBTimeChart.test.tsx +++ b/packages/app/src/components/DBTimeChart/__tests__/DBTimeChart.test.tsx @@ -44,11 +44,11 @@ jest.mock('@/source', () => ({ .mockReturnValue({ formatByColumn: new Map(), chartFormat: undefined }), })); -jest.mock('../MaterializedViews/MVOptimizationIndicator', () => +jest.mock('@/components/MaterializedViews/MVOptimizationIndicator', () => jest.fn(() => null), ); -jest.mock('../charts/DateRangeIndicator', () => jest.fn(() => null)); +jest.mock('@/components/charts/DateRangeIndicator', () => jest.fn(() => null)); describe('DBTimeChart', () => { const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock; diff --git a/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts b/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts new file mode 100644 index 0000000000..57f2e17e62 --- /dev/null +++ b/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts @@ -0,0 +1,179 @@ +import { + type ChartConfigWithDateRange, + SourceKind, + TSource, +} from '@hyperdx/common-utils/dist/types'; + +import { buildSeriesSearchUrl } from '@/components/DBTimeChart/searchUrl'; + +// The URL string itself is ChartUtils' concern and already covered there. What +// matters here is the branching this function does before delegating — which was +// previously locked inside a useCallback and untestable. +type SearchUrlArgs = { + dateRange: [Date, Date]; + groupFilters: { column: string; value: string }[]; + valueRangeFilter?: { expression: string; value: number }; +}; + +const mockBuildEventsSearchUrl = jest.fn( + () => '/search?mocked=1', +); +jest.mock('@/ChartUtils', () => ({ + ...jest.requireActual('@/ChartUtils'), + buildEventsSearchUrl: (arg: SearchUrlArgs) => mockBuildEventsSearchUrl(arg), +})); + +/** Args the function handed to buildEventsSearchUrl on its first call. */ +const delegatedArgs = () => mockBuildEventsSearchUrl.mock.calls[0][0]; + +const source = { + id: 'src-1', + kind: SourceKind.Log, + name: 'logs', + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'otel_logs' }, + timestampValueExpression: 'Timestamp', +} as unknown as TSource; + +const clicked = new Date('2026-01-01T00:00:00Z'); + +const args = { + clickedActiveLabelDate: clicked as Date | undefined, + source: source as TSource | undefined, + granularity: '1 minute', + groupColumns: [] as string[], + valueColumns: undefined as string[] | undefined, + isSingleValueColumn: true as boolean | undefined, +}; + +const configWith = ( + select: { aggFn: string; valueExpression: string }[], +): ChartConfigWithDateRange => + ({ + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'otel_logs' }, + timestampValueExpression: 'Timestamp', + where: '', + select, + dateRange: [clicked, clicked], + }) as ChartConfigWithDateRange; + +const rawSqlConfig = { + connection: 'conn-1', + configType: 'sql', + sqlTemplate: 'SELECT 1', + dateRange: [clicked, clicked], +} satisfies Partial as ChartConfigWithDateRange; + +beforeEach(() => jest.clearAllMocks()); + +describe('buildSeriesSearchUrl', () => { + describe('returns null when drill-down cannot be supported', () => { + it('with no clicked date', () => { + expect( + buildSeriesSearchUrl({ + ...args, + clickedActiveLabelDate: undefined, + config: configWith([{ aggFn: 'avg', valueExpression: 'Duration' }]), + }), + ).toBeNull(); + expect(mockBuildEventsSearchUrl).not.toHaveBeenCalled(); + }); + + it('with no resolved source', () => { + expect( + buildSeriesSearchUrl({ + ...args, + source: undefined, + config: configWith([{ aggFn: 'avg', valueExpression: 'Duration' }]), + }), + ).toBeNull(); + }); + + it('for a raw SQL chart', () => { + // Raw SQL doesn't resolve to a single source, so there is nothing to search. + expect( + buildSeriesSearchUrl({ + ...args, + config: rawSqlConfig, + }), + ).toBeNull(); + }); + }); + + it('ranges from the clicked bucket to one granularity later', () => { + buildSeriesSearchUrl({ + ...args, + config: configWith([{ aggFn: 'avg', valueExpression: 'Duration' }]), + }); + const { dateRange } = delegatedArgs(); + expect(dateRange[0]).toEqual(clicked); + expect(dateRange[1].getTime() - clicked.getTime()).toBe(60_000); + }); + + describe('value-range filter', () => { + it('is added for an attributable aggregation', () => { + buildSeriesSearchUrl({ + ...args, + seriesValue: 250, + config: configWith([{ aggFn: 'max', valueExpression: 'Duration' }]), + }); + const { valueRangeFilter } = delegatedArgs(); + expect(valueRangeFilter).toEqual({ + expression: 'Duration', + value: 250, + }); + }); + + it('is omitted for a non-attributable aggregation', () => { + // A `count`/`sum` point is not attributable to any single event's value, so + // filtering on it would return rows that never contributed to the point. + buildSeriesSearchUrl({ + ...args, + seriesValue: 250, + config: configWith([{ aggFn: 'count', valueExpression: 'Duration' }]), + }); + const { valueRangeFilter } = delegatedArgs(); + expect(valueRangeFilter).toBeUndefined(); + }); + + it('is omitted when no series value was clicked', () => { + buildSeriesSearchUrl({ + ...args, + config: configWith([{ aggFn: 'max', valueExpression: 'Duration' }]), + }); + const { valueRangeFilter } = delegatedArgs(); + expect(valueRangeFilter).toBeUndefined(); + }); + + it('resolves the value column by series-key prefix on a multi-value chart', () => { + // With more than one value column the series key is prefixed with the + // column name, so the filter must follow that prefix to the right select + // item rather than defaulting to select[0]. + buildSeriesSearchUrl({ + ...args, + seriesKey: 'p95', + seriesValue: 900, + isSingleValueColumn: false, + valueColumns: ['count', 'p95'], + config: configWith([ + { aggFn: 'count', valueExpression: 'Body' }, + { aggFn: 'p95', valueExpression: 'Duration' }, + ]), + }); + const { valueRangeFilter } = delegatedArgs(); + expect(valueRangeFilter).toEqual({ expression: 'Duration', value: 900 }); + }); + }); + + it('passes decoded group filters through', () => { + buildSeriesSearchUrl({ + ...args, + seriesKey: 'api', + groupColumns: ['ServiceName'], + config: configWith([{ aggFn: 'avg', valueExpression: 'Duration' }]), + }); + const { groupFilters } = delegatedArgs(); + expect(groupFilters).toEqual([{ column: 'ServiceName', value: 'api' }]); + }); +}); diff --git a/packages/app/src/components/DBTimeChart/crossChartPin.ts b/packages/app/src/components/DBTimeChart/crossChartPin.ts new file mode 100644 index 0000000000..1fda2b8a08 --- /dev/null +++ b/packages/app/src/components/DBTimeChart/crossChartPin.ts @@ -0,0 +1,34 @@ +import { useCallback, useEffect, useId, useRef } from 'react'; + +// Only one pinned tooltip at a time across all charts. Module-level (not +// context) because charts can be scattered with no common provider, and their +// onClick stopPropagation hides cross-chart clicks from Mantine's click-outside. +const pinnedTooltipRegistry = new Map void>(); + +function broadcastTooltipPinned(activeId: string) { + pinnedTooltipRegistry.forEach((dismiss, id) => { + if (id !== activeId) { + dismiss(); + } + }); +} + +// Registers this chart's dismiss handler and returns a callback to close every +// other chart's pinned tooltip (call it when pinning this one). +export function useCrossChartPinDismiss(onDismiss: () => void): () => void { + const id = useId(); + // Keep the latest onDismiss without re-subscribing each render. + const onDismissRef = useRef(onDismiss); + useEffect(() => { + onDismissRef.current = onDismiss; + }, [onDismiss]); + + useEffect(() => { + pinnedTooltipRegistry.set(id, () => onDismissRef.current()); + return () => { + pinnedTooltipRegistry.delete(id); + }; + }, [id]); + + return useCallback(() => broadcastTooltipPinned(id), [id]); +} diff --git a/packages/app/src/components/DBTimeChart/index.ts b/packages/app/src/components/DBTimeChart/index.ts new file mode 100644 index 0000000000..80c60236a9 --- /dev/null +++ b/packages/app/src/components/DBTimeChart/index.ts @@ -0,0 +1,7 @@ +/** + * Public surface of the time-chart tile. Split out of a single 1000-line module; + * consumers (and the test files that `jest.mock('@/components/DBTimeChart')`) + * import from here, so the internal file layout stays free to change. + */ +export { DBTimeChart } from './DBTimeChart'; +export { decodeSeriesGroupFilters, type SeriesGroupFilter } from './searchUrl'; diff --git a/packages/app/src/components/DBTimeChart/searchUrl.ts b/packages/app/src/components/DBTimeChart/searchUrl.ts new file mode 100644 index 0000000000..80069a3846 --- /dev/null +++ b/packages/app/src/components/DBTimeChart/searchUrl.ts @@ -0,0 +1,208 @@ +import { add } from 'date-fns'; +import { convertGranularityToSeconds } from '@hyperdx/common-utils/dist/core/utils'; +import { + isBuilderChartConfig, + isPromqlChartConfig, + isRawSqlChartConfig, +} from '@hyperdx/common-utils/dist/guards'; +import { + ChartConfigWithDateRange, + TSource, +} from '@hyperdx/common-utils/dist/types'; + +import { + AGG_FNS, + buildEventsSearchUrl, + ChartKeyJoiner, + tryExpandConfigVariables, +} from '@/ChartUtils'; + +export type SeriesGroupFilter = { column: string; value: string }; + +// Decode a Recharts series key (e.g. "count · error · api") into the +// underlying group-column filters. This is the same decode buildSeriesSearchUrl +// uses, extracted so the focus callback can hand the caller structured filters +// (rather than a display string) to apply to a sibling results list. +export function decodeSeriesGroupFilters({ + seriesKey, + groupColumns, + isSingleValueColumn, +}: { + seriesKey: string | undefined; + groupColumns: string[]; + isSingleValueColumn: boolean | undefined; +}): SeriesGroupFilter[] { + const seriesKeys = seriesKey?.split(ChartKeyJoiner); + const groupFilters: SeriesGroupFilter[] = []; + + if (seriesKeys?.length && groupColumns?.length) { + // When the series has multiple value columns, the key is prefixed with the + // value column name (e.g. "count · error"), so the group values start at + // index 1. (The "no group columns" case the original inline code also + // guarded is impossible here — this block only runs when groupColumns is + // non-empty.) + const startsWithValueColumn = !(isSingleValueColumn ?? true); + const groupValues = startsWithValueColumn + ? seriesKeys.slice(1) + : seriesKeys; + + groupValues.forEach((value, index) => { + if (groupColumns[index] != null) { + groupFilters.push({ column: groupColumns[index], value }); + } + }); + } + + return groupFilters; +} + +/** + * Build the drill-down search URL for a clicked point, or null when the chart + * cannot support it: raw SQL and PromQL charts don't resolve to a single source, + * and a click with no resolved date has no range to filter on. + * + * Pure, and extracted from a useCallback in the chart so the branching here can + * be read and tested without a render — in particular which value column a + * series key maps to, and whether that column's aggregation is attributable to + * individual events at all (a non-attributable agg must not produce a value + * filter, or the drill-down returns rows that never contributed to the point). + */ +export function buildSeriesSearchUrl({ + seriesKey, + seriesValue, + clickedActiveLabelDate, + source, + config, + granularity, + groupColumns, + valueColumns, + isSingleValueColumn, +}: { + seriesKey?: string; + seriesValue?: number; + clickedActiveLabelDate: Date | undefined; + source: TSource | undefined; + config: ChartConfigWithDateRange; + granularity: string; + groupColumns: string[]; + valueColumns: string[] | undefined; + isSingleValueColumn: boolean | undefined; +}): string | null { + // Raw SQL charts are not supported for drill-down as we don't know the source which is being used. + if ( + clickedActiveLabelDate == null || + source == null || + isRawSqlChartConfig(config) || + isPromqlChartConfig(config) + ) { + return null; + } + + // The search page has no variable machinery, so the expressions read here and + // handed to buildEventsSearchUrl must be final SQL/Lucene. `whereLanguage` is + // pinned to buildEventsSearchUrl's default first, since expansion below + // leaves nothing for it to expand. + const expandedConfig = tryExpandConfigVariables({ + ...config, + whereLanguage: config.whereLanguage || 'lucene', + }); + + // Parse the series key to extract group values + const seriesKeys = seriesKey?.split(ChartKeyJoiner); + const groupFilters = decodeSeriesGroupFilters({ + seriesKey, + groupColumns, + isSingleValueColumn, + }); + + // Build value range filter for Y-axis if provided + let valueRangeFilter: + | { + expression: string; + value: number; + } + | undefined; + + // Metric formula configs with hidden operand series project only the formula + // column(s), so value columns no longer map positionally onto `select` — skip + // the value-range filter rather than misattributing a formula value to an + // operand's expression. (With operands shown, the operand columns still map by + // index and formula columns fall past the `< expandedConfig.select.length` + // bound below.) + const operandsHidden = + isBuilderChartConfig(expandedConfig) && + (expandedConfig.formulas?.length ?? 0) > 0 && + expandedConfig.showOperandSeries === false; + + if ( + seriesValue && + !operandsHidden && + Array.isArray(expandedConfig.select) && + expandedConfig.select.length > 0 + ) { + // Determine which value column to filter on + let valueExpression: string | undefined; + + if ((isSingleValueColumn ?? true) && expandedConfig.select.length === 1) { + const firstSelect = expandedConfig.select[0]; + const aggFn = + typeof firstSelect === 'string' ? undefined : firstSelect.aggFn; + // Only add value range filter if the aggregation is attributable + const isAttributable = + AGG_FNS.find(fn => fn.value === aggFn)?.isAttributable !== false; + + if (isAttributable) { + valueExpression = + typeof firstSelect === 'string' + ? firstSelect + : firstSelect.valueExpression; + } + } else if (seriesKeys?.length && (valueColumns?.length ?? 0) > 0) { + const firstPart = seriesKeys[0]; + const valueColumnIndex = valueColumns?.findIndex( + col => col === firstPart, + ); + + if ( + valueColumnIndex != null && + valueColumnIndex >= 0 && + valueColumnIndex < expandedConfig.select.length + ) { + const selectItem = expandedConfig.select[valueColumnIndex]; + const aggFn = + typeof selectItem === 'string' ? undefined : selectItem.aggFn; + // Only add value range filter if the aggregation is attributable + const isAttributable = + AGG_FNS.find(fn => fn.value === aggFn)?.isAttributable !== false; + + if (isAttributable) { + valueExpression = + typeof selectItem === 'string' + ? selectItem + : selectItem.valueExpression; + } + } + } + + if (valueExpression) { + valueRangeFilter = { + expression: valueExpression, + value: seriesValue, + }; + } + } + + // Calculate time range from clicked date and granularity + const from = clickedActiveLabelDate; + const to = add(clickedActiveLabelDate, { + seconds: convertGranularityToSeconds(granularity), + }); + + return buildEventsSearchUrl({ + source, + config: expandedConfig, + dateRange: [from, to], + groupFilters, + valueRangeFilter, + }); +} diff --git a/packages/app/src/components/DBTimeChart/useChartToolbarItems.tsx b/packages/app/src/components/DBTimeChart/useChartToolbarItems.tsx new file mode 100644 index 0000000000..9d67b17b76 --- /dev/null +++ b/packages/app/src/components/DBTimeChart/useChartToolbarItems.tsx @@ -0,0 +1,171 @@ +import React, { useMemo } from 'react'; +import { + type BuilderChartConfigWithDateRange, + type ChartConfigWithDateRange, + DisplayType, + type TSource, +} from '@hyperdx/common-utils/dist/types'; +import { IconChartBar, IconChartLine } from '@tabler/icons-react'; + +import DateRangeIndicator from '@/components/charts/DateRangeIndicator'; +import DisplaySwitcher from '@/components/charts/DisplaySwitcher'; +import HiddenSeriesIndicator from '@/components/charts/HiddenSeriesIndicator'; +import MVOptimizationIndicator from '@/components/MaterializedViews/MVOptimizationIndicator'; + +type UseChartToolbarItemsArgs = { + builderQueriedConfig: BuilderChartConfigWithDateRange | undefined; + config: ChartConfigWithDateRange; + displayType: DisplayType | undefined; + handleSetDisplayType: (displayType: DisplayType) => void; + /** Series materialized but dropped by the chart's render cap. */ + hiddenSeriesCount: number; + /** Render every series, bypassing the cap; undefined when that is a no-op. */ + loadAllHandler: (() => void) | undefined; + renderedSeriesCount: number; + // Shape comes from useMVOptimizationExplanation; only these fields are read here. + mvOptimizationData: + | { + optimizedConfig?: { dateRange?: [Date, Date] }; + explanations: { + success: boolean; + mvConfig: { minGranularity?: string }; + }[]; + } + | undefined; + queriedConfig: ChartConfigWithDateRange; + showDateRangeIndicator: boolean; + showDisplaySwitcher: boolean; + showMVOptimizationIndicator: boolean; + source: TSource | undefined; + toolbarPrefix: React.ReactNode[] | undefined; + toolbarSuffix: React.ReactNode[] | undefined; +}; + +/** + * Assemble the chart's toolbar: caller-supplied prefix/suffix items plus the + * indicators the chart owns (materialized-view optimization, effective date + * range) and the display-type switcher. + * + * Extracted from DBTimeChart because it is a long, purely presentational list + * build with no bearing on the chart's data or interaction state. + */ +export function useChartToolbarItems({ + builderQueriedConfig, + config, + displayType, + handleSetDisplayType, + hiddenSeriesCount, + loadAllHandler, + mvOptimizationData, + renderedSeriesCount, + queriedConfig, + showDateRangeIndicator, + showDisplaySwitcher, + showMVOptimizationIndicator, + source, + toolbarPrefix, + toolbarSuffix, +}: UseChartToolbarItemsArgs) { + return useMemo(() => { + const allToolbarItems = []; + + if (toolbarPrefix && toolbarPrefix.length > 0) { + allToolbarItems.push(...toolbarPrefix); + } + + if (source && showMVOptimizationIndicator && builderQueriedConfig) { + allToolbarItems.push( + , + ); + } + + const mvDateRange = mvOptimizationData?.optimizedConfig?.dateRange; + const isAlignedToChartGranularity = + queriedConfig.alignDateRangeToGranularity !== false; + + if ( + showDateRangeIndicator && + (mvDateRange || isAlignedToChartGranularity) + ) { + const mvGranularity = isAlignedToChartGranularity + ? undefined + : mvOptimizationData?.explanations.find(e => e.success)?.mvConfig + .minGranularity; + + allToolbarItems.push( + , + ); + } + + if (showDisplaySwitcher) { + allToolbarItems.push( + , + }, + { + value: DisplayType.StackedBar, + label: config.compareToPreviousPeriod + ? 'Bar Chart Unavailable When Comparing to Previous Period' + : 'Display as Bar Chart', + icon: , + disabled: config.compareToPreviousPeriod, + }, + ]} + />, + ); + } + + if (hiddenSeriesCount > 0) { + allToolbarItems.push( + , + ); + } + + if (toolbarSuffix && toolbarSuffix.length > 0) { + allToolbarItems.push(...toolbarSuffix); + } + + return allToolbarItems; + }, [ + builderQueriedConfig, + config, + displayType, + handleSetDisplayType, + showDisplaySwitcher, + source, + toolbarPrefix, + toolbarSuffix, + showMVOptimizationIndicator, + showDateRangeIndicator, + mvOptimizationData, + queriedConfig, + hiddenSeriesCount, + renderedSeriesCount, + loadAllHandler, + ]); +} From ab54781d20742d22848e1519c7fef9825108ea0d Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Wed, 5 Aug 2026 22:51:41 +1000 Subject: [PATCH 2/4] fix(app): drill down on a clicked zero, and cover the extracted seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on the chart-file split. The one behaviour change: clicking a point whose value is exactly 0 dropped the value filter and searched every event in the bucket. The chart deliberately keeps zeroes rather than treating them as absent, so a truthiness guard was the wrong test. Deleted packages/app/packages/app/src/components/DBTimeChart/__tests__/ searchUrl.test.ts — a duplicate of the real test, created by a command that ran from the wrong directory. Jest never ran it, so it was free to drift while still costing tsc and eslint time. Tests for the two pure seams the split created and left uncovered: useChartScales (axis domains, the fit-to-data zero clamp, selection filtering, half-bucket bar padding, annotation clamping) and crossChartPin (the module-level registry that coordinates one pinned tooltip across charts). Also: restored a comment about the resize debounce that the move had cut in half across two files; ChartTooltipContent no longer maps over an undefined payload before its own guard, which used to latch the error boundary for the life of the instance; the pinned tooltip shares the hover tooltip's offset constant rather than repeating the literal; MemoChart's graphResults is typed as the hook already narrows it to; useChartToolbarItems derives its mvOptimizationData type from the hook instead of hand-copying the shape; buildSeriesSearchUrl is exported from the barrel it belongs to; and DBTimeChart.tsx imports its own siblings relatively, matching the other new directory. --- .changeset/timechart-drilldown-zero-value.md | 10 + .../DBTimeChart/__tests__/searchUrl.test.ts | 179 ------------------ .../ChartTooltipContent.tsx | 10 +- .../__tests__/useChartScales.test.ts | 166 ++++++++++++++++ .../src/HDXMultiSeriesTimeChart/chartData.ts | 4 - .../DBTimeChart/ChartTooltipOverlay.tsx | 9 +- .../components/DBTimeChart/DBTimeChart.tsx | 17 +- .../__tests__/crossChartPin.test.tsx | 69 +++++++ .../DBTimeChart/__tests__/searchUrl.test.ts | 36 +++- .../app/src/components/DBTimeChart/index.ts | 6 +- .../src/components/DBTimeChart/searchUrl.ts | 5 +- .../DBTimeChart/useChartToolbarItems.tsx | 14 +- 12 files changed, 318 insertions(+), 207 deletions(-) create mode 100644 .changeset/timechart-drilldown-zero-value.md delete mode 100644 packages/app/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts create mode 100644 packages/app/src/HDXMultiSeriesTimeChart/__tests__/useChartScales.test.ts create mode 100644 packages/app/src/components/DBTimeChart/__tests__/crossChartPin.test.tsx diff --git a/.changeset/timechart-drilldown-zero-value.md b/.changeset/timechart-drilldown-zero-value.md new file mode 100644 index 0000000000..0e24af0eb0 --- /dev/null +++ b/.changeset/timechart-drilldown-zero-value.md @@ -0,0 +1,10 @@ +--- +'@hyperdx/app': patch +--- + +fix: keep the drill-down value filter when the clicked point is zero + +Clicking a time-chart point with a value of exactly 0 dropped the value filter +and searched every event in the bucket instead of the matching ones. Zero is a +real point — the chart deliberately keeps it rather than treating it as absent — +so the guard now tests for a missing value rather than a falsy one. diff --git a/packages/app/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts b/packages/app/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts deleted file mode 100644 index 57f2e17e62..0000000000 --- a/packages/app/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { - type ChartConfigWithDateRange, - SourceKind, - TSource, -} from '@hyperdx/common-utils/dist/types'; - -import { buildSeriesSearchUrl } from '@/components/DBTimeChart/searchUrl'; - -// The URL string itself is ChartUtils' concern and already covered there. What -// matters here is the branching this function does before delegating — which was -// previously locked inside a useCallback and untestable. -type SearchUrlArgs = { - dateRange: [Date, Date]; - groupFilters: { column: string; value: string }[]; - valueRangeFilter?: { expression: string; value: number }; -}; - -const mockBuildEventsSearchUrl = jest.fn( - () => '/search?mocked=1', -); -jest.mock('@/ChartUtils', () => ({ - ...jest.requireActual('@/ChartUtils'), - buildEventsSearchUrl: (arg: SearchUrlArgs) => mockBuildEventsSearchUrl(arg), -})); - -/** Args the function handed to buildEventsSearchUrl on its first call. */ -const delegatedArgs = () => mockBuildEventsSearchUrl.mock.calls[0][0]; - -const source = { - id: 'src-1', - kind: SourceKind.Log, - name: 'logs', - connection: 'conn-1', - from: { databaseName: 'default', tableName: 'otel_logs' }, - timestampValueExpression: 'Timestamp', -} as unknown as TSource; - -const clicked = new Date('2026-01-01T00:00:00Z'); - -const args = { - clickedActiveLabelDate: clicked as Date | undefined, - source: source as TSource | undefined, - granularity: '1 minute', - groupColumns: [] as string[], - valueColumns: undefined as string[] | undefined, - isSingleValueColumn: true as boolean | undefined, -}; - -const configWith = ( - select: { aggFn: string; valueExpression: string }[], -): ChartConfigWithDateRange => - ({ - connection: 'conn-1', - from: { databaseName: 'default', tableName: 'otel_logs' }, - timestampValueExpression: 'Timestamp', - where: '', - select, - dateRange: [clicked, clicked], - }) as ChartConfigWithDateRange; - -const rawSqlConfig = { - connection: 'conn-1', - configType: 'sql', - sqlTemplate: 'SELECT 1', - dateRange: [clicked, clicked], -} satisfies Partial as ChartConfigWithDateRange; - -beforeEach(() => jest.clearAllMocks()); - -describe('buildSeriesSearchUrl', () => { - describe('returns null when drill-down cannot be supported', () => { - it('with no clicked date', () => { - expect( - buildSeriesSearchUrl({ - ...args, - clickedActiveLabelDate: undefined, - config: configWith([{ aggFn: 'avg', valueExpression: 'Duration' }]), - }), - ).toBeNull(); - expect(mockBuildEventsSearchUrl).not.toHaveBeenCalled(); - }); - - it('with no resolved source', () => { - expect( - buildSeriesSearchUrl({ - ...args, - source: undefined, - config: configWith([{ aggFn: 'avg', valueExpression: 'Duration' }]), - }), - ).toBeNull(); - }); - - it('for a raw SQL chart', () => { - // Raw SQL doesn't resolve to a single source, so there is nothing to search. - expect( - buildSeriesSearchUrl({ - ...args, - config: rawSqlConfig, - }), - ).toBeNull(); - }); - }); - - it('ranges from the clicked bucket to one granularity later', () => { - buildSeriesSearchUrl({ - ...args, - config: configWith([{ aggFn: 'avg', valueExpression: 'Duration' }]), - }); - const { dateRange } = delegatedArgs(); - expect(dateRange[0]).toEqual(clicked); - expect(dateRange[1].getTime() - clicked.getTime()).toBe(60_000); - }); - - describe('value-range filter', () => { - it('is added for an attributable aggregation', () => { - buildSeriesSearchUrl({ - ...args, - seriesValue: 250, - config: configWith([{ aggFn: 'max', valueExpression: 'Duration' }]), - }); - const { valueRangeFilter } = delegatedArgs(); - expect(valueRangeFilter).toEqual({ - expression: 'Duration', - value: 250, - }); - }); - - it('is omitted for a non-attributable aggregation', () => { - // A `count`/`sum` point is not attributable to any single event's value, so - // filtering on it would return rows that never contributed to the point. - buildSeriesSearchUrl({ - ...args, - seriesValue: 250, - config: configWith([{ aggFn: 'count', valueExpression: 'Duration' }]), - }); - const { valueRangeFilter } = delegatedArgs(); - expect(valueRangeFilter).toBeUndefined(); - }); - - it('is omitted when no series value was clicked', () => { - buildSeriesSearchUrl({ - ...args, - config: configWith([{ aggFn: 'max', valueExpression: 'Duration' }]), - }); - const { valueRangeFilter } = delegatedArgs(); - expect(valueRangeFilter).toBeUndefined(); - }); - - it('resolves the value column by series-key prefix on a multi-value chart', () => { - // With more than one value column the series key is prefixed with the - // column name, so the filter must follow that prefix to the right select - // item rather than defaulting to select[0]. - buildSeriesSearchUrl({ - ...args, - seriesKey: 'p95', - seriesValue: 900, - isSingleValueColumn: false, - valueColumns: ['count', 'p95'], - config: configWith([ - { aggFn: 'count', valueExpression: 'Body' }, - { aggFn: 'p95', valueExpression: 'Duration' }, - ]), - }); - const { valueRangeFilter } = delegatedArgs(); - expect(valueRangeFilter).toEqual({ expression: 'Duration', value: 900 }); - }); - }); - - it('passes decoded group filters through', () => { - buildSeriesSearchUrl({ - ...args, - seriesKey: 'api', - groupColumns: ['ServiceName'], - config: configWith([{ aggFn: 'avg', valueExpression: 'Duration' }]), - }); - const { groupFilters } = delegatedArgs(); - expect(groupFilters).toEqual([{ column: 'ServiceName', value: 'api' }]); - }); -}); diff --git a/packages/app/src/HDXMultiSeriesTimeChart/ChartTooltipContent.tsx b/packages/app/src/HDXMultiSeriesTimeChart/ChartTooltipContent.tsx index 00560fa721..ad024247e1 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart/ChartTooltipContent.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart/ChartTooltipContent.tsx @@ -31,6 +31,9 @@ type HDXLineChartTooltipProps = { containerRef: React.MutableRefObject; } & Record; +/** Stable stand-in for a missing payload, so the memo below keeps its identity. */ +const EMPTY_PAYLOAD: TooltipPayload[] = []; + /** * The recharts `` content used for the HOVER tooltip (on the hovered * chart and its synced followers). Clicking pins ChartSeriesTooltip instead. @@ -53,7 +56,12 @@ export const HDXLineChartTooltip = withErrorBoundary( activePointYByKeyRef, containerRef, } = props; - const typedPayload = payload as TooltipPayload[]; + // recharts calls this with no payload when nothing is hovered, and the memo + // below runs before the `active && payload` guard. Mapping over undefined + // there throws inside the render, which withErrorBoundary latches for the + // rest of this instance's life — the chart never recovers. A shared constant + // rather than `?? []` so the memo's dependency stays referentially stable. + const typedPayload = (payload ?? EMPTY_PAYLOAD) as TooltipPayload[]; const tooltipZIndex = useChartTooltipZIndex(); diff --git a/packages/app/src/HDXMultiSeriesTimeChart/__tests__/useChartScales.test.ts b/packages/app/src/HDXMultiSeriesTimeChart/__tests__/useChartScales.test.ts new file mode 100644 index 0000000000..7f5f3029cd --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/__tests__/useChartScales.test.ts @@ -0,0 +1,166 @@ +import { DisplayType } from '@hyperdx/common-utils/dist/types'; +import { renderHook } from '@testing-library/react'; + +import { ChartAnnotation } from '@/components/charts/chartAnnotations'; +import { useChartScales } from '@/HDXMultiSeriesTimeChart/useChartScales'; + +/** + * The pure seam this refactor created: axis domains and annotation elements + * derived from props, with no state and no recharts tree. The branching that + * used to be buried in MemoChart — fit-to-data, legend selection, zero-anchored + * bars, the half-bucket bar padding — is what these cases pin. + */ +const lineData = [ + { dataKey: 'a', displayName: 'A' }, + { dataKey: 'b', displayName: 'B' }, +] as any; + +const baseArgs = { + annotations: undefined as ChartAnnotation[] | undefined, + containerWidth: 800, + dateRange: [ + new Date('2026-01-01T00:00:00Z'), + new Date('2026-01-01T01:00:00Z'), + ] as readonly [Date, Date], + granularity: '1 minute', + dateRangeEndInclusive: true, + displayType: DisplayType.Line, + fitYAxisToData: false, + graphResults: [ + { a: 10, b: 20 }, + { a: 30, b: 40 }, + ] as Record[], + lineData, + selectedSeriesNames: undefined as Set | undefined, +}; + +const scales = (overrides: Partial = {}) => + renderHook(() => useChartScales({ ...baseArgs, ...overrides })).result + .current; + +describe('useChartScales y-domain', () => { + it('lets recharts auto-scale from zero with no selection and no fit', () => { + expect(scales().yAxisDomain).toEqual([0, 'auto']); + }); + + it('fits the lower bound to the data minimum, less padding', () => { + // min 10, max 40 -> 5% padding is 1.5. + const [lower, upper] = scales({ fitYAxisToData: true }).yAxisDomain as [ + number, + number, + ]; + expect(lower).toBe(8.5); + expect(upper).toBe(41.5); + }); + + it('does not let the padding drag the axis below zero', () => { + // A chart of durations whose axis starts at -4ms reads as broken, so the + // clamp only lifts when the data itself never goes negative. + const [lower] = scales({ + fitYAxisToData: true, + graphResults: [{ a: 1, b: 100 }], + }).yAxisDomain as [number, number]; + expect(lower).toBe(0); + }); + + it('follows the data minimum when fitting and the data is negative', () => { + // min -50, max 25 -> 5% of the 75 range is 3.75, applied to both ends. + expect( + scales({ + fitYAxisToData: true, + graphResults: [ + { a: -50, b: -10 }, + { a: 0, b: 25 }, + ], + }).yAxisDomain, + ).toEqual([-53.75, 28.75]); + }); + + it('ignores deselected series when computing the range', () => { + // A alone spans 10..30, so padding is 1 and the domain is [9, 31]. With B + // included it would be [8.5, 41.5] — asserting the exact pair catches a + // wrong padding factor too, which a `toBeLessThan(40)` bound would not. + expect(scales({ selectedSeriesNames: new Set(['A']) }).yAxisDomain).toEqual( + [9, 31], + ); + }); + + it('bars stay anchored at zero even when fitting is requested', () => { + // Otherwise bar lengths stop being proportional to their values. + expect( + scales({ + fitYAxisToData: true, + displayType: DisplayType.StackedBar, + graphResults: [{ a: 100, b: 110 }], + }).yAxisDomain, + ).toEqual([0, 'auto']); + }); + + it('falls back to auto when no numeric values are present', () => { + expect( + scales({ + selectedSeriesNames: new Set(['A']), + graphResults: [{ a: null, b: 'x' }] as Record[], + }).yAxisDomain, + ).toEqual(['auto', 'auto']); + }); +}); + +describe('useChartScales x-domain', () => { + it('spans the requested range in seconds', () => { + const [start, end] = scales().xAxisDomain; + expect(end - start).toBe(3600); + }); + + it('drops the final bucket when the end is exclusive and boundary-aligned', () => { + // An exclusive end that lands exactly on a bucket boundary would otherwise + // render an extra empty bucket at the right edge. + const [, end] = scales({ dateRangeEndInclusive: false }).xAxisDomain; + const [, inclusiveEnd] = scales().xAxisDomain; + expect(inclusiveEnd - end).toBe(60); + }); + + it('pads both edges by half a bucket for bars so the full width fits', () => { + const [start, end] = scales({ + displayType: DisplayType.StackedBar, + }).xAxisDomain; + const [plainStart, plainEnd] = scales().xAxisDomain; + expect(plainStart - start).toBe(30); + expect(end - plainEnd).toBe(30); + }); +}); + +describe('useChartScales annotations', () => { + // `x` is the only thing worth asserting here: the element count is the same + // whatever time the annotation carries, so a test that only checks for a + // non-null result passes even when the time is read from the wrong field and + // every marker lands on NaN. + const markerX = (annotations: ChartAnnotation[]) => + (scales({ annotations }).annotationElements ?? []).map( + el => (el.props as { x: number }).x, + ); + + it('renders nothing when there are none', () => { + expect(scales().annotationElements).toBeNull(); + expect(scales({ annotations: [] }).annotationElements).toBeNull(); + }); + + it('places a marker at its own time, in the same unix seconds as the domain', () => { + const time = new Date('2026-01-01T00:30:00Z'); + expect(markerX([{ time, label: 'deploy' }])).toEqual([ + time.getTime() / 1000, + ]); + }); + + it('snaps a marker outside the window to the nearest edge', () => { + // An alert already firing when the window opens has a timestamp before the + // range; recharts drops such a marker outright, so it is clamped instead. + const [start, end] = scales().xAxisDomain; + expect( + markerX([ + { time: new Date('2025-12-25T00:00:00Z') }, + { time: new Date('2026-06-01T00:00:00Z') }, + ]), + ).toEqual([start, end]); + }); +}); diff --git a/packages/app/src/HDXMultiSeriesTimeChart/chartData.ts b/packages/app/src/HDXMultiSeriesTimeChart/chartData.ts index 2c62321724..9a4a841de0 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart/chartData.ts +++ b/packages/app/src/HDXMultiSeriesTimeChart/chartData.ts @@ -4,10 +4,6 @@ import { formatNumber } from '@/utils'; export const HARD_LINES_LIMIT = MAX_TIME_CHART_SERIES; -// Debounce (ms) for the chart's ResponsiveContainer resize observer. Without -// it the observer fires on every frame, and a resize → re-render → resize -// cycle can keep the chart (and the form controls around it in the tile - /** One series entry in a tooltip's per-bucket payload (hover or click-frozen). */ export type ActiveClickSeries = { value?: number; diff --git a/packages/app/src/components/DBTimeChart/ChartTooltipOverlay.tsx b/packages/app/src/components/DBTimeChart/ChartTooltipOverlay.tsx index c50019f084..9c98843336 100644 --- a/packages/app/src/components/DBTimeChart/ChartTooltipOverlay.tsx +++ b/packages/app/src/components/DBTimeChart/ChartTooltipOverlay.tsx @@ -4,7 +4,10 @@ import { Popover, Portal } from '@mantine/core'; import { ChartSeriesTooltip } from '@/components/charts/ChartSeriesTooltip'; import { useChartTooltipZIndex } from '@/components/charts/ChartTooltip'; -import type { ActiveClickPayload } from '@/HDXMultiSeriesTimeChart'; +import { + type ActiveClickPayload, + TOOLTIP_POINT_OFFSET_PX, +} from '@/HDXMultiSeriesTimeChart'; // The interactive PINNED tooltip, rendered over the chart in a body-portaled // Mantine Popover anchored at the clicked point. Hover uses the recharts tooltip @@ -114,7 +117,9 @@ export function ChartTooltipOverlay({ trapFocus={false} withinPortal position="bottom" - offset={12} + // Same gap the hover tooltip uses, so the two states don't sit at + // different distances from the point. + offset={TOOLTIP_POINT_OFFSET_PX} middlewares={{ flip: true, shift: true }} returnFocus={false} zIndex={popoverZIndex} diff --git a/packages/app/src/components/DBTimeChart/DBTimeChart.tsx b/packages/app/src/components/DBTimeChart/DBTimeChart.tsx index 491aa7f943..b4217a6349 100644 --- a/packages/app/src/components/DBTimeChart/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart/DBTimeChart.tsx @@ -21,14 +21,6 @@ import ChartContainer from '@/components/charts/ChartContainer'; import ChartErrorState, { ChartErrorStateVariant, } from '@/components/charts/ChartErrorState'; -import { ChartTooltipOverlay } from '@/components/DBTimeChart/ChartTooltipOverlay'; -import { useCrossChartPinDismiss } from '@/components/DBTimeChart/crossChartPin'; -import { - buildSeriesSearchUrl, - decodeSeriesGroupFilters, - type SeriesGroupFilter, -} from '@/components/DBTimeChart/searchUrl'; -import { useChartToolbarItems } from '@/components/DBTimeChart/useChartToolbarItems'; import { MAX_LOADABLE_TIME_CHART_SERIES, resolveRenderedSeriesCap, @@ -38,6 +30,15 @@ import { useQueriedChartConfig } from '@/hooks/useChartConfig'; import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation'; import { useChartNumberFormats, useSource } from '@/source'; +import { ChartTooltipOverlay } from './ChartTooltipOverlay'; +import { useCrossChartPinDismiss } from './crossChartPin'; +import { + buildSeriesSearchUrl, + decodeSeriesGroupFilters, + type SeriesGroupFilter, +} from './searchUrl'; +import { useChartToolbarItems } from './useChartToolbarItems'; + type DBTimeChartComponentProps = { config: ChartConfigWithDateRange; disableQueryChunking?: boolean; diff --git a/packages/app/src/components/DBTimeChart/__tests__/crossChartPin.test.tsx b/packages/app/src/components/DBTimeChart/__tests__/crossChartPin.test.tsx new file mode 100644 index 0000000000..03273a7396 --- /dev/null +++ b/packages/app/src/components/DBTimeChart/__tests__/crossChartPin.test.tsx @@ -0,0 +1,69 @@ +import { renderHook } from '@testing-library/react'; + +import { useCrossChartPinDismiss } from '@/components/DBTimeChart/crossChartPin'; + +/** + * The registry backing this hook is module-level and survives renders, unmounts + * and every test in this file's worker — deliberately, because charts can be + * scattered with no common provider. That makes leaks between consumers a real + * failure mode rather than a theoretical one, so the cases below cover both + * directions: who gets dismissed, and who stops being reachable. + */ +describe('useCrossChartPinDismiss', () => { + it('dismisses the other chart but not the one doing the pinning', () => { + const dismissA = jest.fn(); + const dismissB = jest.fn(); + + const a = renderHook(() => useCrossChartPinDismiss(dismissA)); + renderHook(() => useCrossChartPinDismiss(dismissB)); + + a.result.current(); + + expect(dismissB).toHaveBeenCalledTimes(1); + expect(dismissA).not.toHaveBeenCalled(); + }); + + it('stops calling a consumer once it unmounts', () => { + const dismissA = jest.fn(); + const dismissB = jest.fn(); + + const a = renderHook(() => useCrossChartPinDismiss(dismissA)); + const b = renderHook(() => useCrossChartPinDismiss(dismissB)); + + b.unmount(); + a.result.current(); + + // A stale entry here would call into an unmounted component's setState on + // every pin, for the life of the page. + expect(dismissB).not.toHaveBeenCalled(); + }); + + it('calls the latest callback, not the one from the first render', () => { + const first = jest.fn(); + const second = jest.fn(); + + const a = renderHook(() => useCrossChartPinDismiss(jest.fn())); + const b = renderHook(({ cb }) => useCrossChartPinDismiss(cb), { + initialProps: { cb: first }, + }); + + b.rerender({ cb: second }); + a.result.current(); + + expect(second).toHaveBeenCalledTimes(1); + expect(first).not.toHaveBeenCalled(); + }); + + it('dismisses every other consumer, not just one', () => { + const dismissA = jest.fn(); + const others = [jest.fn(), jest.fn(), jest.fn()]; + + const a = renderHook(() => useCrossChartPinDismiss(dismissA)); + others.forEach(cb => renderHook(() => useCrossChartPinDismiss(cb))); + + a.result.current(); + + others.forEach(cb => expect(cb).toHaveBeenCalledTimes(1)); + expect(dismissA).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts b/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts index 57f2e17e62..04a9355f13 100644 --- a/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts +++ b/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts @@ -4,7 +4,7 @@ import { TSource, } from '@hyperdx/common-utils/dist/types'; -import { buildSeriesSearchUrl } from '@/components/DBTimeChart/searchUrl'; +import { buildSeriesSearchUrl } from '@/components/DBTimeChart'; // The URL string itself is ChartUtils' concern and already covered there. What // matters here is the branching this function does before delegating — which was @@ -65,6 +65,13 @@ const rawSqlConfig = { dateRange: [clicked, clicked], } satisfies Partial as ChartConfigWithDateRange; +const promqlConfig = { + connection: 'conn-1', + configType: 'promql', + promqlExpression: 'up', + dateRange: [clicked, clicked], +} satisfies Partial as ChartConfigWithDateRange; + beforeEach(() => jest.clearAllMocks()); describe('buildSeriesSearchUrl', () => { @@ -99,6 +106,17 @@ describe('buildSeriesSearchUrl', () => { }), ).toBeNull(); }); + + it('for a PromQL chart', () => { + // Same reason as raw SQL, and covered separately because the two share one + // condition — without this, dropping the isPromqlChartConfig arm stays green. + expect( + buildSeriesSearchUrl({ + ...args, + config: promqlConfig, + }), + ).toBeNull(); + }); }); it('ranges from the clicked bucket to one granularity later', () => { @@ -146,6 +164,22 @@ describe('buildSeriesSearchUrl', () => { expect(valueRangeFilter).toBeUndefined(); }); + it('is added for a clicked value of zero', () => { + // Zero is a real point — buildActiveClickSeries keeps it rather than + // dropping it — so a truthiness guard here would silently widen the + // drill-down to every event in the bucket. + buildSeriesSearchUrl({ + ...args, + seriesValue: 0, + config: configWith([{ aggFn: 'max', valueExpression: 'Duration' }]), + }); + const { valueRangeFilter } = delegatedArgs(); + expect(valueRangeFilter).toEqual({ + expression: 'Duration', + value: 0, + }); + }); + it('resolves the value column by series-key prefix on a multi-value chart', () => { // With more than one value column the series key is prefixed with the // column name, so the filter must follow that prefix to the right select diff --git a/packages/app/src/components/DBTimeChart/index.ts b/packages/app/src/components/DBTimeChart/index.ts index 80c60236a9..45a76286ed 100644 --- a/packages/app/src/components/DBTimeChart/index.ts +++ b/packages/app/src/components/DBTimeChart/index.ts @@ -4,4 +4,8 @@ * import from here, so the internal file layout stays free to change. */ export { DBTimeChart } from './DBTimeChart'; -export { decodeSeriesGroupFilters, type SeriesGroupFilter } from './searchUrl'; +export { + buildSeriesSearchUrl, + decodeSeriesGroupFilters, + type SeriesGroupFilter, +} from './searchUrl'; diff --git a/packages/app/src/components/DBTimeChart/searchUrl.ts b/packages/app/src/components/DBTimeChart/searchUrl.ts index 80069a3846..cf27ff21af 100644 --- a/packages/app/src/components/DBTimeChart/searchUrl.ts +++ b/packages/app/src/components/DBTimeChart/searchUrl.ts @@ -134,8 +134,11 @@ export function buildSeriesSearchUrl({ (expandedConfig.formulas?.length ?? 0) > 0 && expandedConfig.showOperandSeries === false; + // `!= null`, not truthiness: a clicked value of exactly 0 is a real point — + // buildActiveClickSeries preserves zeroes — and skipping the filter for it + // drills down to every event in the bucket instead of the matching ones. if ( - seriesValue && + seriesValue != null && !operandsHidden && Array.isArray(expandedConfig.select) && expandedConfig.select.length > 0 diff --git a/packages/app/src/components/DBTimeChart/useChartToolbarItems.tsx b/packages/app/src/components/DBTimeChart/useChartToolbarItems.tsx index 9d67b17b76..8ee586e11f 100644 --- a/packages/app/src/components/DBTimeChart/useChartToolbarItems.tsx +++ b/packages/app/src/components/DBTimeChart/useChartToolbarItems.tsx @@ -11,6 +11,7 @@ import DateRangeIndicator from '@/components/charts/DateRangeIndicator'; import DisplaySwitcher from '@/components/charts/DisplaySwitcher'; import HiddenSeriesIndicator from '@/components/charts/HiddenSeriesIndicator'; import MVOptimizationIndicator from '@/components/MaterializedViews/MVOptimizationIndicator'; +import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation'; type UseChartToolbarItemsArgs = { builderQueriedConfig: BuilderChartConfigWithDateRange | undefined; @@ -22,16 +23,9 @@ type UseChartToolbarItemsArgs = { /** Render every series, bypassing the cap; undefined when that is a no-op. */ loadAllHandler: (() => void) | undefined; renderedSeriesCount: number; - // Shape comes from useMVOptimizationExplanation; only these fields are read here. - mvOptimizationData: - | { - optimizedConfig?: { dateRange?: [Date, Date] }; - explanations: { - success: boolean; - mvConfig: { minGranularity?: string }; - }[]; - } - | undefined; + // Derived from the hook rather than hand-copied, so a change to its shape is + // a type error here instead of a field that quietly stops being read. + mvOptimizationData: ReturnType['data']; queriedConfig: ChartConfigWithDateRange; showDateRangeIndicator: boolean; showDisplaySwitcher: boolean; From 0356599abebee4f123d796278262ff742285f22a Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 6 Aug 2026 08:01:37 +1000 Subject: [PATCH 3/4] fix(app): type the useChartScales test fixtures instead of asserting Main ratcheted the app warning ceiling from 740 to 663, and the four assertions this test file used to sidestep typing put the branch over it. Typed the LineData fixture properly, asserted the whole y-domain rather than destructuring a cast, and narrowed the annotation elements with isValidElement so a rename of `x` would be a type error rather than a silently passing test. --- .../__tests__/useChartScales.test.ts | 45 +++++++++++-------- .../DBTimeChart/__tests__/searchUrl.test.ts | 5 ++- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/packages/app/src/HDXMultiSeriesTimeChart/__tests__/useChartScales.test.ts b/packages/app/src/HDXMultiSeriesTimeChart/__tests__/useChartScales.test.ts index 7f5f3029cd..838945bac3 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart/__tests__/useChartScales.test.ts +++ b/packages/app/src/HDXMultiSeriesTimeChart/__tests__/useChartScales.test.ts @@ -1,6 +1,8 @@ +import { isValidElement } from 'react'; import { DisplayType } from '@hyperdx/common-utils/dist/types'; import { renderHook } from '@testing-library/react'; +import { type LineData } from '@/ChartUtils'; import { ChartAnnotation } from '@/components/charts/chartAnnotations'; import { useChartScales } from '@/HDXMultiSeriesTimeChart/useChartScales'; @@ -10,10 +12,16 @@ import { useChartScales } from '@/HDXMultiSeriesTimeChart/useChartScales'; * used to be buried in MemoChart — fit-to-data, legend selection, zero-anchored * bars, the half-bucket bar padding — is what these cases pin. */ -const lineData = [ - { dataKey: 'a', displayName: 'A' }, - { dataKey: 'b', displayName: 'B' }, -] as any; +const series = (dataKey: string, displayName: string): LineData => ({ + dataKey, + displayName, + currentPeriodKey: dataKey, + previousPeriodKey: `${dataKey}-prev`, + valueColumnName: dataKey, + color: '#000000', +}); + +const lineData: LineData[] = [series('a', 'A'), series('b', 'B')]; const baseArgs = { annotations: undefined as ChartAnnotation[] | undefined, @@ -45,22 +53,19 @@ describe('useChartScales y-domain', () => { it('fits the lower bound to the data minimum, less padding', () => { // min 10, max 40 -> 5% padding is 1.5. - const [lower, upper] = scales({ fitYAxisToData: true }).yAxisDomain as [ - number, - number, - ]; - expect(lower).toBe(8.5); - expect(upper).toBe(41.5); + expect(scales({ fitYAxisToData: true }).yAxisDomain).toEqual([8.5, 41.5]); }); it('does not let the padding drag the axis below zero', () => { - // A chart of durations whose axis starts at -4ms reads as broken, so the - // clamp only lifts when the data itself never goes negative. - const [lower] = scales({ - fitYAxisToData: true, - graphResults: [{ a: 1, b: 100 }], - }).yAxisDomain as [number, number]; - expect(lower).toBe(0); + // min 1, max 100 -> padding 4.95 would put the floor at -3.95, and a chart of + // durations whose axis starts below zero reads as broken. The clamp only + // lifts when the data itself goes negative. + expect( + scales({ + fitYAxisToData: true, + graphResults: [{ a: 1, b: 100 }], + }).yAxisDomain, + ).toEqual([0, 104.95]); }); it('follows the data minimum when fitting and the data is negative', () => { @@ -136,8 +141,10 @@ describe('useChartScales annotations', () => { // non-null result passes even when the time is read from the wrong field and // every marker lands on NaN. const markerX = (annotations: ChartAnnotation[]) => - (scales({ annotations }).annotationElements ?? []).map( - el => (el.props as { x: number }).x, + (scales({ annotations }).annotationElements ?? []).map(el => + // A guard rather than a cast: the elements come back as ReactElement with + // unknown props, and asserting the shape would hide a rename of `x`. + isValidElement<{ x: number }>(el) ? el.props.x : undefined, ); it('renders nothing when there are none', () => { diff --git a/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts b/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts index 04a9355f13..c03ebb3ca0 100644 --- a/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts +++ b/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts @@ -26,14 +26,15 @@ jest.mock('@/ChartUtils', () => ({ /** Args the function handed to buildEventsSearchUrl on its first call. */ const delegatedArgs = () => mockBuildEventsSearchUrl.mock.calls[0][0]; -const source = { +const source: TSource = { id: 'src-1', kind: SourceKind.Log, name: 'logs', connection: 'conn-1', from: { databaseName: 'default', tableName: 'otel_logs' }, timestampValueExpression: 'Timestamp', -} as unknown as TSource; + defaultTableSelectExpression: 'Body', +}; const clicked = new Date('2026-01-01T00:00:00Z'); From 478f9aa78b555557a17d34f33e4ac145fffb2506 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Tue, 6 Oct 2026 14:14:58 +1100 Subject: [PATCH 4/4] docs(changeset): describe the zero drill-down fix by what users see --- .changeset/timechart-drilldown-zero-value.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.changeset/timechart-drilldown-zero-value.md b/.changeset/timechart-drilldown-zero-value.md index 0e24af0eb0..862b3da3ff 100644 --- a/.changeset/timechart-drilldown-zero-value.md +++ b/.changeset/timechart-drilldown-zero-value.md @@ -2,9 +2,7 @@ '@hyperdx/app': patch --- -fix: keep the drill-down value filter when the clicked point is zero +fix: drill down correctly from a time chart point with a value of 0 -Clicking a time-chart point with a value of exactly 0 dropped the value filter -and searched every event in the bucket instead of the matching ones. Zero is a -real point — the chart deliberately keeps it rather than treating it as absent — -so the guard now tests for a missing value rather than a falsy one. +Clicking a point with a value of 0 searched every event in that time bucket. It +now filters to events with a value of 0, as it already did for any other value.