diff --git a/.changeset/timechart-drilldown-zero-value.md b/.changeset/timechart-drilldown-zero-value.md new file mode 100644 index 0000000000..862b3da3ff --- /dev/null +++ b/.changeset/timechart-drilldown-zero-value.md @@ -0,0 +1,8 @@ +--- +'@hyperdx/app': patch +--- + +fix: drill down correctly from a time chart point with a value of 0 + +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. diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx deleted file mode 100644 index 3de997cf70..0000000000 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ /dev/null @@ -1,2190 +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 { getTickValuesFixedDomain } from 'recharts/lib/util/scale/getNiceTickValues'; -import { CurveType } from 'recharts/types/shape/Curve'; -import { AxisDomain } from 'recharts/types/util/types'; -import { convertGranularityToSeconds } from '@hyperdx/common-utils/dist/core/utils'; -import { - DisplayType, - getDefaultLineInterpolation, - LineInterpolation, -} from '@hyperdx/common-utils/dist/types'; -import { Popover } from '@mantine/core'; - -import type { NumberFormat } from '@/types'; -import { - COLORS, - formatDurationMsCompact, - formatNumber, - isFixedNumericUnit, - 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 { useContentFontSize } from './useUserPreferences'; - -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'), - ]), - ); -} - -// Cap on the axis mantissa search - configured Decimals can go up to 10 -// (NumberFormat.tsx), but 2 already distinguishes values >= 0.005 from 0. -const MAX_AXIS_MANTISSA = 2; - -/** - * Base width ceiling for a bare signed number - see MAX_AXIS_MANTISSA's - * comment. A character budget rather than a pixel one, which is why - * Y_AXIS_WIDTH scales with the content font size preference: the gutter has to - * grow with the tick font for this to keep holding at every setting. - */ -const AXIS_CHAR_BUDGET = 5; - -// Flat, not suffix-length-scaled - IBM Plex Mono is monospace, so a longer -// suffix costs the same per character as a digit and earns no extra room. -const SEPARATOR_CHAR_ALLOWANCE = 1; - -// Trims insignificant trailing zeros ("1.00k" -> "1k") and a sign left -// over from a value that rounded to zero ("-0"/"-0%" -> "0"/"0%"). -function trimTrailingZeros(formatted: string): string { - const trimmed = formatted - .replace(/(\.\d*?)0+(?=\D*$)/, '$1') - .replace(/\.(?=\D*$)/, ''); - return trimmed.replace(/^-(0%?)$/, '$1'); -} - -// A space-separated unit suffix gets its own allowance - an overflowing -// right-anchored SVG label clips off-canvas, so there's no safe rescue here. -function axisLabelBudget(formatted: string): number { - const spaceIndex = formatted.indexOf(' '); - if (spaceIndex !== -1) { - return AXIS_CHAR_BUDGET + SEPARATOR_CHAR_ALLOWANCE; - } - const isNegativePercent = - formatted.startsWith('-') && formatted.endsWith('%'); - return AXIS_CHAR_BUDGET + (isNegativePercent ? 1 : 0); -} - -/** - * Searches downward from the configured mantissa for the tightest fit - * (axisLabelBudget); diverges from DBHeatmapChart's tickFormatter deliberately. - */ -export function formatAxisTick( - value: number, - axisNumberFormat?: NumberFormat, -): string { - if (!axisNumberFormat) { - return new Intl.NumberFormat('en-US', { - notation: 'compact', - compactDisplay: 'short', - }).format(value); - } - - // formatNumber returns early for 'duration', before the mantissa/width - // safety below ever runs, and formatDurationMs has no width budget of its - // own - use the compact formatter instead, as DBHeatmapChart's axis does. - if (axisNumberFormat.output === 'duration') { - const factor = axisNumberFormat.factor ?? 1; - return formatDurationMsCompact(value * factor * 1000); - } - - const maxMantissa = Math.max( - 0, - Math.min(axisNumberFormat.mantissa ?? 0, MAX_AXIS_MANTISSA), - ); - // A fixed unit's suffix is identical on every tick, so it's dropped here - // (unlike an auto-scale one) to spend the whole budget on precision. - const isFixedUnit = isFixedNumericUnit(axisNumberFormat.numericUnit); - for (let mantissa = maxMantissa; mantissa >= 0; mantissa--) { - const candidate = trimTrailingZeros( - isFixedUnit - ? value.toFixed(mantissa) - : formatNumber(value, { - ...axisNumberFormat, - mantissa, - average: true, - unit: undefined, - }), - ); - if (mantissa === 0 || candidate.length <= axisLabelBudget(candidate)) { - return candidate; - } - } - // Unreachable: the mantissa === 0 case above always returns. - return ''; -} - -// Retries with fewer ticks until every label is distinct, so survivors -// stay evenly spaced instead of an uneven subset of a fixed-size set. -export function getYAxisTicks( - min: number, - max: number, - formatTick: (value: number) => string, -): number[] { - for (let tickCount = 5; tickCount >= 2; tickCount--) { - const candidates = getTickValuesFixedDomain([min, max], tickCount, true); - const labels = candidates.map(formatTick); - if (new Set(labels).size === labels.length) { - return candidates; - } - } - // Nothing distinguishes this range at this mantissa - fall back to the - // full set, redundant labels and all, rather than misrepresent it as flat. - return getTickValuesFixedDomain([min, max], 5, true); -} - -// Rounds off float dust (e.g. 3 * 1.05 giving 3.1500000000000004). -const cleanNumber = (v: number) => Number(v.toPrecision(12)); - -// A format with no decimals to spend forces every tick to an integer, -// where a 2.5x10^n step (n <= 0) rounds unevenly - see niceStepsNear. -function forcesIntegerTicks(axisNumberFormat?: NumberFormat): boolean { - if (!axisNumberFormat) return true; - if (axisNumberFormat.output === 'duration') return false; - return (axisNumberFormat.mantissa ?? 0) === 0; -} - -// Every 1/2/5 x10^n step, plus 2.5x10^n for n > 0 (integers like 250 are -// always safe; 2.5, 0.25, ... round unevenly with no decimals to spare). -function niceStepsNear( - target: number, - axisNumberFormat?: NumberFormat, -): number[] { - const exp = Math.floor(Math.log10(target)); - const excludeSmall2_5 = forcesIntegerTicks(axisNumberFormat); - return [exp - 1, exp, exp + 1] - .flatMap(e => { - const includeQuarterStep = e > 0 || !excludeSmall2_5; - const multipliers = includeQuarterStep - ? [1, 2, 2.5, 5, 10] - : [1, 2, 5, 10]; - return multipliers.map(m => m * 10 ** e); - }) - .filter(step => step > 0) - .sort((a, b) => a - b); -} - -// null means the step is unusable at this magnitude (see below), which -// getNiceYAxisTicks must treat as a rejection, not as a short tick list. -function ticksWithinRange( - step: number, - min: number, - max: number, -): number[] | null { - const ticks: number[] = []; - let t = cleanNumber(Math.ceil(min / step) * step); - while (t <= max + step * 1e-9) { - ticks.push(t); - const next = cleanNumber(t + step); - // At extreme magnitudes, float precision can make this step a no-op - - // reject it rather than accept a truncated, collapsed tick list. - if (next <= t) { - return null; - } - t = next; - } - return ticks; -} - -// Escalates precision past the configured mantissa, bypassing -// formatAxisTick's own selection (which can force 0 regardless). -function formatTickAtMantissa( - value: number, - axisNumberFormat: NumberFormat, - mantissa: number, -): string { - // Mirrors formatAxisTick's fixed-unit handling - re-adding the suffix it - // drops would make budget checks reject a label that never actually renders that wide. - if (isFixedNumericUnit(axisNumberFormat.numericUnit)) { - return trimTrailingZeros(value.toFixed(mantissa)); - } - return trimTrailingZeros( - formatNumber(value, { - ...axisNumberFormat, - mantissa, - average: true, - unit: undefined, - }), - ); -} - -// Ticks must never carry duplicate labels. -const MAX_TICK_MANTISSA_ESCALATION = 4; - -function isDistinct( - ticks: number[], - formatTick: (value: number) => string, -): boolean { - return new Set(ticks.map(formatTick)).size === ticks.length; -} - -// Reuses formatAxisTick's own per-label budget (axisLabelBudget already -// accounts for a space-separated unit suffix or a negative percent sign). -function fitsLabelBudget( - ticks: number[], - formatTick: (value: number) => string, -): boolean { - return ticks.every(t => { - const label = formatTick(t); - return label.length <= axisLabelBudget(label); - }); -} - -// Prefers formatAxisTick's normal output, but escalates precision past the -// configured mantissa when that's the only way to keep labels distinct. -function resolveDistinctTickLabels( - ticks: number[], - axisNumberFormat: NumberFormat | undefined, -): ((value: number) => string) | null { - const base = (value: number) => formatAxisTick(value, axisNumberFormat); - if (isDistinct(ticks, base)) { - return base; - } - if (!axisNumberFormat) { - // No configured mantissa to escalate - fall back to full, non-compact - // precision, which always distinguishes any two different numbers. - const fullPrecision = (value: number) => - new Intl.NumberFormat('en-US').format(value); - return isDistinct(ticks, fullPrecision) && - fitsLabelBudget(ticks, fullPrecision) - ? fullPrecision - : null; - } - if (axisNumberFormat.output === 'duration') { - // formatDurationMsCompact has no mantissa - escalate its own fixed - // 2-3 significant digits instead, past whichever unit it picks. - const factor = axisNumberFormat.factor ?? 1; - for (let p = 3; p <= 3 + MAX_TICK_MANTISSA_ESCALATION; p++) { - const escalated = (value: number) => - formatDurationMsCompact(value * factor * 1000, p); - if (isDistinct(ticks, escalated) && fitsLabelBudget(ticks, escalated)) { - return escalated; - } - } - return null; - } - // formatAxisTick can force mantissa down to 0 regardless of what's - // configured, so escalation must start from 1, not the configured value. - for (let m = 1; m <= MAX_TICK_MANTISSA_ESCALATION; m++) { - const escalated = (value: number) => - formatTickAtMantissa(value, axisNumberFormat, m); - if (isDistinct(ticks, escalated) && fitsLabelBudget(ticks, escalated)) { - return escalated; - } - } - return null; -} - -export interface NiceYAxisTicks { - ticks: number[]; - tickFormatter?: (value: number) => string; -} - -// Ported from packages/cli/src/termchart/scale.ts's niceTicks: the smallest -// step that fits within [min, max]/maxTicks and formats to distinct labels. -export function getNiceYAxisTicks( - min: number, - max: number, - maxTicks = 5, - axisNumberFormat?: NumberFormat, -): NiceYAxisTicks { - if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) { - return { ticks: [] }; - } - const steps = niceStepsNear((max - min) / (maxTicks - 1), axisNumberFormat); - for (const step of steps) { - const ticks = ticksWithinRange(step, min, max); - // A single tick conveys no scale at all - never accept it, even - // though its "labels" are trivially distinct from one another. - if (!ticks || ticks.length < 2 || ticks.length > maxTicks) { - continue; - } - const tickFormatter = resolveDistinctTickLabels(ticks, axisNumberFormat); - if (tickFormatter) { - return { ticks, tickFormatter }; - } - } - return { ticks: [] }; -} - -export interface ExpandableYAxisTicks { - max: number; - ticks: number[]; - tickFormatter?: (value: number) => string; -} - -// For the plain default branch (domain may expand, as [0,'auto'] did -// pre-PR): rounds the upper bound up to fill slack under maxTicks. -export function getExpandableYAxisTicks( - min: number, - max: number, - maxTicks = 5, - axisNumberFormat?: NumberFormat, -): ExpandableYAxisTicks { - if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) { - return { max, ticks: [] }; - } - const steps = niceStepsNear((max - min) / (maxTicks - 1), axisNumberFormat); - for (const step of steps) { - const tightTicks = ticksWithinRange(step, min, max); - // A single tick conveys no scale at all - never accept it, even - // though its "labels" are trivially distinct from one another. - if (!tightTicks || tightTicks.length < 2 || tightTicks.length > maxTicks) { - continue; - } - const tightFormatter = resolveDistinctTickLabels( - tightTicks, - axisNumberFormat, - ); - if (!tightFormatter) { - continue; - } - const expandedMax = cleanNumber(Math.ceil(max / step) * step); - // Filling a step's worth of dead space is fine, but not at the cost - // of a large fraction of the range - keep the tight result instead. - if (expandedMax - max <= (max - min) * 0.25) { - const expandedTicks = ticksWithinRange(step, min, expandedMax); - if (expandedTicks && expandedTicks.length <= maxTicks) { - const expandedFormatter = resolveDistinctTickLabels( - expandedTicks, - axisNumberFormat, - ); - if (expandedFormatter) { - return { - max: expandedMax, - ticks: expandedTicks, - tickFormatter: expandedFormatter, - }; - } - } - } - return { max, ticks: tightTicks, tickFormatter: tightFormatter }; - } - return { max, ticks: [] }; -} - -// Shared by every yAxisDomain branch below. Callers pass only the series -// actually drawn (already selection- and HARD_LINES_LIMIT-filtered). -export function scanYAxisValueRange( - graphResults: any[], - lineData: LineData[], -): { min: number; max: number } { - let min = Infinity; - let max = -Infinity; - graphResults.forEach(dataPoint => { - lineData.forEach(ld => { - const value = dataPoint[ld.dataKey]; - if (typeof value === 'number' && !isNaN(value)) { - min = Math.min(min, value); - max = Math.max(max, value); - } - }); - }); - return { min, max }; -} - -export interface YAxisBounds { - domain: AxisDomain; - ticks: number[] | undefined; - tickFormatter?: (value: number) => string; -} - -// Stable identity for the referenceLineValues default - a fresh `[]` literal -// as a default prop value defeats memoization and can loop React's compiler. -const EMPTY_REFERENCE_LINE_VALUES: number[] = []; - -const DEFAULT_Y_AXIS_BOUNDS: YAxisBounds = { - domain: [0, 'auto'], - ticks: undefined, -}; -const FIT_Y_AXIS_BOUNDS: YAxisBounds = { - domain: ['auto', 'auto'], - ticks: undefined, -}; - -const CURVE_TYPE_BY_INTERPOLATION: Record = { - smooth: 'monotone', - linear: 'linear', - step: 'stepAfter', -}; - -export function getLineCurveType( - displayType: DisplayType, - lineInterpolation: LineInterpolation | undefined, -): CurveType { - return CURVE_TYPE_BY_INTERPOLATION[ - lineInterpolation ?? getDefaultLineInterpolation(displayType) - ]; -} - -// A stacked bar's rendered height sums its series at each timestamp - leave -// that entirely to Recharts, regardless of selection/fit-to-data state. -export function computeYAxisBounds( - graphResults: any[], - visibleLineData: LineData[], - hasSelection: boolean, - fitYAxisToData: boolean, - displayType: DisplayType, - referenceLineValues: number[], - axisNumberFormat?: NumberFormat, -): YAxisBounds { - if ( - displayType === DisplayType.StackedBar || - displayType === DisplayType.StackedLine - ) { - return DEFAULT_Y_AXIS_BOUNDS; - } - const shouldFitYAxis = fitYAxisToData; - - if (!hasSelection && !shouldFitYAxis) { - // A fully numeric domain skips Recharts' own nice rounding, and a - // reference line can extend it further - defer to Recharts entirely. - if (referenceLineValues.length > 0) { - return DEFAULT_Y_AXIS_BOUNDS; - } - const { min, max } = scanYAxisValueRange(graphResults, visibleLineData); - if (max === -Infinity) { - return DEFAULT_Y_AXIS_BOUNDS; - } - // Recharts widens an explicit domain to fit out-of-range data, so - // negative data must be reflected here, not just pinned at zero. - const lowerBound = cleanNumber(Math.min(0, min)); - // max * 1.05 would shrink the upper bound below max for negative data; - // padding away from zero keeps headroom regardless of max's sign. - const upperBound = cleanNumber(max + Math.abs(max) * 0.05); - if (upperBound <= lowerBound) { - return DEFAULT_Y_AXIS_BOUNDS; - } - const expanded = getExpandableYAxisTicks( - lowerBound, - upperBound, - 5, - axisNumberFormat, - ); - // No nice step fits - fall back to getYAxisTicks' reduce-tick-count - // dedup instead of Recharts' raw, collision-prone default. - if (expanded.ticks.length === 0) { - const baseFormat = (value: number) => - formatAxisTick(value, axisNumberFormat); - return { - domain: [lowerBound, upperBound], - ticks: getYAxisTicks(lowerBound, upperBound, baseFormat), - tickFormatter: baseFormat, - }; - } - return { - domain: [lowerBound, expanded.max], - ticks: expanded.ticks, - tickFormatter: expanded.tickFormatter, - }; - } - - // A selection with fit-to-data off still keeps the zero-pinned fallback, - // not the unpinned fit fallback - only fitting itself opts out of it. - const degenerateFallback = shouldFitYAxis - ? FIT_Y_AXIS_BOUNDS - : DEFAULT_Y_AXIS_BOUNDS; - const { min, max } = scanYAxisValueRange(graphResults, visibleLineData); - if (min === Infinity || max === -Infinity) { - return degenerateFallback; - } - const padding = (max - min) * 0.05; - // Recharts widens the domain to actual negative data regardless of fit - // mode, so the lower bound must follow it whenever min itself is negative. - const lowerBound = cleanNumber( - min < 0 ? min - padding : Math.max(0, min - padding), - ); - const upperBound = cleanNumber(max + padding); - if (upperBound <= lowerBound) { - return degenerateFallback; - } - // A reference line can widen the domain (extendDomain) - extend it up - // front and nice-step the result, instead of ticking a stale domain. - if (referenceLineValues.length > 0) { - const extendedLower = cleanNumber( - Math.min(lowerBound, ...referenceLineValues), - ); - const extendedUpper = cleanNumber( - Math.max(upperBound, ...referenceLineValues), - ); - const expanded = getExpandableYAxisTicks( - extendedLower, - extendedUpper, - 5, - axisNumberFormat, - ); - if (expanded.ticks.length > 0) { - return { - domain: [extendedLower, expanded.max], - ticks: expanded.ticks, - tickFormatter: expanded.tickFormatter, - }; - } - const baseFormat = (value: number) => - formatAxisTick(value, axisNumberFormat); - return { - domain: [extendedLower, extendedUpper], - ticks: getYAxisTicks(extendedLower, extendedUpper, baseFormat), - tickFormatter: baseFormat, - }; - } - const { ticks, tickFormatter } = getNiceYAxisTicks( - lowerBound, - upperBound, - 5, - axisNumberFormat, - ); - // Same fallback as the default branch above - reduce tick count via - // getYAxisTicks rather than leaving this to Recharts' raw default. - if (ticks.length === 0) { - const baseFormat = (value: number) => - formatAxisTick(value, axisNumberFormat); - return { - domain: [lowerBound, upperBound], - ticks: getYAxisTicks(lowerBound, upperBound, baseFormat), - tickFormatter: baseFormat, - }; - } - return { - domain: [lowerBound, upperBound], - ticks, - tickFormatter, - }; -} - -export const MemoChart = memo(function MemoChart({ - graphResults, - setIsClickActive, - refreshClickActive, - isClickActive, - dateRange, - lineData, - referenceLines, - referenceLineValues = EMPTY_REFERENCE_LINE_VALUES, - annotations, - logReferenceTimestamp, - displayType = DisplayType.Line, - axisNumberFormat, - fallbackNumberFormat, - tooltipNumberFormatsByKey, - isLoading, - timestampKey = 'ts_bucket', - onTimeRangeSelect, - showLegend = true, - previousPeriodOffsetSeconds, - selectedSeriesNames, - onToggleSeries, - onClearSeriesSelection, - granularity, - dateRangeEndInclusive = true, - fitYAxisToData = false, - lineInterpolation, -}: { - 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; - // Raw numeric value(s) backing referenceLines (pre-rendered JSX the axis - // math can't read), used to size the Y-axis domain around them. - referenceLineValues?: number[]; - /** - * 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; - /** Unset follows the display type's default (see getLineCurveType). */ - lineInterpolation?: LineInterpolation; -}) { - const _id = useId(); - const id = _id.replace(/:/g, ''); - - // The y-axis width budget is expressed in characters (see MAX_AXIS_MANTISSA), - // so it has to grow with the tick font or the widest labels clip. - const { compact: axisTickFontSize, scale: contentFontScale } = - useContentFontSize(); - const yAxisWidth = Math.round(Y_AXIS_WIDTH * contentFontScale); - - // 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(() => { - const isStackedLine = displayType === DisplayType.StackedLine; - const curveType = getLineCurveType(displayType, lineInterpolation); - 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={} - // Stacked bands get a flat fill; the gradient fades out within the - // top 10% of the chart, which leaves stacked bands looking like - // bare lines. Hiding the fill 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={ - isStackedLine - ? color - : `url(#time-chart-lin-grad-${id}-${color?.replace('#', '').toLowerCase()})` - } - strokeDasharray={strokeDasharray} - name={seriesName} - isAnimationActive={false} - connectNulls - /> - ); - }); - }, [ - visibleLineData, - displayType, - lineInterpolation, - id, - captureActivePointY, - ]); - - // Nearest-cursor emphasis (thicken the nearest line, fade the rest) applied - // via a tiny scoped - ); - }, [nearestSeriesKey, visibleLineData.length, id]); - - const yAxisBounds = useMemo( - () => - computeYAxisBounds( - graphResults, - visibleLineData, - hasSeriesSelection(selectedSeriesNames), - fitYAxisToData, - displayType, - referenceLineValues, - axisNumberFormat, - ), - [ - graphResults, - visibleLineData, - selectedSeriesNames, - fitYAxisToData, - displayType, - referenceLineValues, - axisNumberFormat, - ], - ); - const yAxisDomain = yAxisBounds.domain; - - 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 - yAxisWidth - 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, yAxisWidth]); - - 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 yAxisTicks = yAxisBounds.ticks; - // computeYAxisBounds already resolves ticks+formatter together per domain. - const yAxisTickFormatter = yAxisBounds.tickFormatter ?? tickFormatter; - - 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 - yAxisWidth), - }); - }, [coloredAnnotations, xAxisDomain, containerWidth, yAxisWidth]); - - 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 - yAxisWidth), - }); - }, [coloredAnnotations, xAxisDomain, containerWidth, yAxisWidth]); - - 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} - // Stacked lines are read by their filled bands, so keep the fill on - // hover; the chartHovered fill-hiding rule opts out via this attribute. - data-stacked-fill={ - displayType === DisplayType.StackedLine ? 'true' : undefined - } - 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..ad024247e1 --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/ChartTooltipContent.tsx @@ -0,0 +1,190 @@ +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; + +/** 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. + * + * 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; + // 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(); + + 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..edfcbc39d7 --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx @@ -0,0 +1,929 @@ +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, + LineInterpolation, +} 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 { useContentFontSize } from '@/useUserPreferences'; +import { COLORS } from '@/utils'; + +import { formatAxisTick } from './axisTicks'; +import { + type ActiveClickPayload, + buildActiveClickSeries, + type ChartMouseState, + 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 { getLineCurveType, 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; + +// Stable identity for the referenceLineValues default - a fresh `[]` literal +// as a default prop value defeats memoization and can loop React's compiler. +const EMPTY_REFERENCE_LINE_VALUES: number[] = []; + +/** + * 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, + referenceLineValues = EMPTY_REFERENCE_LINE_VALUES, + annotations, + logReferenceTimestamp, + displayType = DisplayType.Line, + axisNumberFormat, + fallbackNumberFormat, + tooltipNumberFormatsByKey, + isLoading, + timestampKey = 'ts_bucket', + onTimeRangeSelect, + showLegend = true, + previousPeriodOffsetSeconds, + selectedSeriesNames, + onToggleSeries, + onClearSeriesSelection, + granularity, + dateRangeEndInclusive = true, + fitYAxisToData = false, + lineInterpolation, +}: { + // 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; + // Raw numeric value(s) backing referenceLines (pre-rendered JSX the axis + // math can't read), used to size the Y-axis domain around them. + referenceLineValues?: number[]; + /** + * 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; + /** Unset follows the display type's default (see getLineCurveType). */ + lineInterpolation?: LineInterpolation; +}) { + const _id = useId(); + const id = _id.replace(/:/g, ''); + + // The y-axis width budget is expressed in characters (see MAX_AXIS_MANTISSA), + // so it has to grow with the tick font or the widest labels clip. + const { compact: axisTickFontSize, scale: contentFontScale } = + useContentFontSize(); + const yAxisWidth = Math.round(Y_AXIS_WIDTH * contentFontScale); + + // 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(() => { + const isStackedLine = displayType === DisplayType.StackedLine; + const curveType = getLineCurveType(displayType, lineInterpolation); + 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={} + // Stacked bands get a flat fill; the gradient fades out within the + // top 10% of the chart, which leaves stacked bands looking like + // bare lines. Hiding the fill 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={ + isStackedLine + ? color + : `url(#time-chart-lin-grad-${id}-${color?.replace('#', '').toLowerCase()})` + } + strokeDasharray={strokeDasharray} + name={seriesName} + isAnimationActive={false} + connectNulls + /> + ); + }); + }, [ + visibleLineData, + displayType, + lineInterpolation, + 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 { yAxisBounds, xAxisDomain, annotationElements, laidOutAnnotations } = + useChartScales({ + annotations, + containerWidth, + dateRange, + granularity, + dateRangeEndInclusive, + displayType, + fitYAxisToData, + graphResults, + lineData, + visibleLineData, + selectedSeriesNames, + referenceLineValues, + axisNumberFormat, + yAxisWidth, + }); + const yAxisDomain = yAxisBounds.domain; + + // 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 - yAxisWidth - 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, yAxisWidth]); + + 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 yAxisTicks = yAxisBounds.ticks; + // computeYAxisBounds already resolves ticks+formatter together per domain. + const yAxisTickFormatter = yAxisBounds.tickFormatter ?? tickFormatter; + + 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} + // Stacked lines are read by their filled bands, so keep the fill on + // hover; the chartHovered fill-hiding rule opts out via this attribute. + data-stacked-fill={ + displayType === DisplayType.StackedLine ? 'true' : undefined + } + 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/__tests__/useChartScales.test.ts b/packages/app/src/HDXMultiSeriesTimeChart/__tests__/useChartScales.test.ts new file mode 100644 index 0000000000..1a76f46ad8 --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/__tests__/useChartScales.test.ts @@ -0,0 +1,197 @@ +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 { getVisibleLineData } from '@/HDXMultiSeriesTimeChart/chartData'; +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 series = (dataKey: string, displayName: string): LineData => ({ + dataKey, + displayName, + currentPeriodKey: dataKey, + previousPeriodKey: `${dataKey}-prev`, + valueColumnName: dataKey, + groupValues: [], + color: '#000000', +}); + +const lineData: LineData[] = [series('a', 'A'), series('b', 'B')]; + +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, + referenceLineValues: [] as number[], + yAxisWidth: 40, +}; + +// visibleLineData mirrors MemoChart, which derives it from the selection. +const scales = (overrides: Partial = {}) => { + const args = { ...baseArgs, ...overrides }; + return renderHook(() => + useChartScales({ + ...args, + visibleLineData: getVisibleLineData( + args.lineData, + args.selectedSeriesNames, + ), + axisNumberFormat: undefined, + }), + ).result.current; +}; + +describe('useChartScales y-domain', () => { + it('pins zero and pads the max by 5% with no selection and no fit', () => { + expect(scales().yAxisBounds.domain).toEqual([0, 42]); + }); + + it('defers to recharts from zero when a reference line could widen it', () => { + expect(scales({ referenceLineValues: [100] }).yAxisBounds.domain).toEqual([ + 0, + 'auto', + ]); + }); + + it('fits the lower bound to the data minimum, less padding', () => { + // min 10, max 40 -> 5% padding is 1.5. + expect(scales({ fitYAxisToData: true }).yAxisBounds.domain).toEqual([ + 8.5, 41.5, + ]); + }); + + it('does not let the padding drag the axis below zero', () => { + // 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 }], + }).yAxisBounds.domain, + ).toEqual([0, 104.95]); + }); + + 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 }, + ], + }).yAxisBounds.domain, + ).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']) }).yAxisBounds.domain, + ).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 }], + }).yAxisBounds.domain, + ).toEqual([0, 'auto']); + }); + + it('keeps zero pinned when a selection has no numeric values', () => { + expect( + scales({ + selectedSeriesNames: new Set(['A']), + graphResults: [{ a: null, b: 'x' }] as Record[], + }).yAxisBounds.domain, + ).toEqual([0, '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 => + // 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', () => { + 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/axisTicks.ts b/packages/app/src/HDXMultiSeriesTimeChart/axisTicks.ts new file mode 100644 index 0000000000..dc92d25e03 --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/axisTicks.ts @@ -0,0 +1,343 @@ +import { getTickValuesFixedDomain } from 'recharts/lib/util/scale/getNiceTickValues'; + +import type { NumberFormat } from '@/types'; +import { + formatDurationMsCompact, + formatNumber, + isFixedNumericUnit, +} from '@/utils'; + +// Cap on the axis mantissa search - configured Decimals can go up to 10 +// (NumberFormat.tsx), but 2 already distinguishes values >= 0.005 from 0. +const MAX_AXIS_MANTISSA = 2; + +/** + * Base width ceiling for a bare signed number - see MAX_AXIS_MANTISSA's + * comment. A character budget rather than a pixel one, which is why + * Y_AXIS_WIDTH scales with the content font size preference: the gutter has to + * grow with the tick font for this to keep holding at every setting. + */ +const AXIS_CHAR_BUDGET = 5; + +// Flat, not suffix-length-scaled - IBM Plex Mono is monospace, so a longer +// suffix costs the same per character as a digit and earns no extra room. +const SEPARATOR_CHAR_ALLOWANCE = 1; + +// Trims insignificant trailing zeros ("1.00k" -> "1k") and a sign left +// over from a value that rounded to zero ("-0"/"-0%" -> "0"/"0%"). +function trimTrailingZeros(formatted: string): string { + const trimmed = formatted + .replace(/(\.\d*?)0+(?=\D*$)/, '$1') + .replace(/\.(?=\D*$)/, ''); + return trimmed.replace(/^-(0%?)$/, '$1'); +} + +// A space-separated unit suffix gets its own allowance - an overflowing +// right-anchored SVG label clips off-canvas, so there's no safe rescue here. +function axisLabelBudget(formatted: string): number { + const spaceIndex = formatted.indexOf(' '); + if (spaceIndex !== -1) { + return AXIS_CHAR_BUDGET + SEPARATOR_CHAR_ALLOWANCE; + } + const isNegativePercent = + formatted.startsWith('-') && formatted.endsWith('%'); + return AXIS_CHAR_BUDGET + (isNegativePercent ? 1 : 0); +} + +/** + * Searches downward from the configured mantissa for the tightest fit + * (axisLabelBudget); diverges from DBHeatmapChart's tickFormatter deliberately. + */ +export function formatAxisTick( + value: number, + axisNumberFormat?: NumberFormat, +): string { + if (!axisNumberFormat) { + return new Intl.NumberFormat('en-US', { + notation: 'compact', + compactDisplay: 'short', + }).format(value); + } + + // formatNumber returns early for 'duration', before the mantissa/width + // safety below ever runs, and formatDurationMs has no width budget of its + // own - use the compact formatter instead, as DBHeatmapChart's axis does. + if (axisNumberFormat.output === 'duration') { + const factor = axisNumberFormat.factor ?? 1; + return formatDurationMsCompact(value * factor * 1000); + } + + const maxMantissa = Math.max( + 0, + Math.min(axisNumberFormat.mantissa ?? 0, MAX_AXIS_MANTISSA), + ); + // A fixed unit's suffix is identical on every tick, so it's dropped here + // (unlike an auto-scale one) to spend the whole budget on precision. + const isFixedUnit = isFixedNumericUnit(axisNumberFormat.numericUnit); + for (let mantissa = maxMantissa; mantissa >= 0; mantissa--) { + const candidate = trimTrailingZeros( + isFixedUnit + ? value.toFixed(mantissa) + : formatNumber(value, { + ...axisNumberFormat, + mantissa, + average: true, + unit: undefined, + }), + ); + if (mantissa === 0 || candidate.length <= axisLabelBudget(candidate)) { + return candidate; + } + } + // Unreachable: the mantissa === 0 case above always returns. + return ''; +} + +// Retries with fewer ticks until every label is distinct, so survivors +// stay evenly spaced instead of an uneven subset of a fixed-size set. +export function getYAxisTicks( + min: number, + max: number, + formatTick: (value: number) => string, +): number[] { + for (let tickCount = 5; tickCount >= 2; tickCount--) { + const candidates = getTickValuesFixedDomain([min, max], tickCount, true); + const labels = candidates.map(formatTick); + if (new Set(labels).size === labels.length) { + return candidates; + } + } + // Nothing distinguishes this range at this mantissa - fall back to the + // full set, redundant labels and all, rather than misrepresent it as flat. + return getTickValuesFixedDomain([min, max], 5, true); +} + +// Rounds off float dust (e.g. 3 * 1.05 giving 3.1500000000000004). +export const cleanNumber = (v: number) => Number(v.toPrecision(12)); + +// A format with no decimals to spend forces every tick to an integer, +// where a 2.5x10^n step (n <= 0) rounds unevenly - see niceStepsNear. +function forcesIntegerTicks(axisNumberFormat?: NumberFormat): boolean { + if (!axisNumberFormat) return true; + if (axisNumberFormat.output === 'duration') return false; + return (axisNumberFormat.mantissa ?? 0) === 0; +} + +// Every 1/2/5 x10^n step, plus 2.5x10^n for n > 0 (integers like 250 are +// always safe; 2.5, 0.25, ... round unevenly with no decimals to spare). +function niceStepsNear( + target: number, + axisNumberFormat?: NumberFormat, +): number[] { + const exp = Math.floor(Math.log10(target)); + const excludeSmall2_5 = forcesIntegerTicks(axisNumberFormat); + return [exp - 1, exp, exp + 1] + .flatMap(e => { + const includeQuarterStep = e > 0 || !excludeSmall2_5; + const multipliers = includeQuarterStep + ? [1, 2, 2.5, 5, 10] + : [1, 2, 5, 10]; + return multipliers.map(m => m * 10 ** e); + }) + .filter(step => step > 0) + .sort((a, b) => a - b); +} + +// null means the step is unusable at this magnitude (see below), which +// getNiceYAxisTicks must treat as a rejection, not as a short tick list. +function ticksWithinRange( + step: number, + min: number, + max: number, +): number[] | null { + const ticks: number[] = []; + let t = cleanNumber(Math.ceil(min / step) * step); + while (t <= max + step * 1e-9) { + ticks.push(t); + const next = cleanNumber(t + step); + // At extreme magnitudes, float precision can make this step a no-op - + // reject it rather than accept a truncated, collapsed tick list. + if (next <= t) { + return null; + } + t = next; + } + return ticks; +} + +// Escalates precision past the configured mantissa, bypassing +// formatAxisTick's own selection (which can force 0 regardless). +function formatTickAtMantissa( + value: number, + axisNumberFormat: NumberFormat, + mantissa: number, +): string { + // Mirrors formatAxisTick's fixed-unit handling - re-adding the suffix it + // drops would make budget checks reject a label that never actually renders that wide. + if (isFixedNumericUnit(axisNumberFormat.numericUnit)) { + return trimTrailingZeros(value.toFixed(mantissa)); + } + return trimTrailingZeros( + formatNumber(value, { + ...axisNumberFormat, + mantissa, + average: true, + unit: undefined, + }), + ); +} + +// Ticks must never carry duplicate labels. +const MAX_TICK_MANTISSA_ESCALATION = 4; + +function isDistinct( + ticks: number[], + formatTick: (value: number) => string, +): boolean { + return new Set(ticks.map(formatTick)).size === ticks.length; +} + +// Reuses formatAxisTick's own per-label budget (axisLabelBudget already +// accounts for a space-separated unit suffix or a negative percent sign). +function fitsLabelBudget( + ticks: number[], + formatTick: (value: number) => string, +): boolean { + return ticks.every(t => { + const label = formatTick(t); + return label.length <= axisLabelBudget(label); + }); +} + +// Prefers formatAxisTick's normal output, but escalates precision past the +// configured mantissa when that's the only way to keep labels distinct. +function resolveDistinctTickLabels( + ticks: number[], + axisNumberFormat: NumberFormat | undefined, +): ((value: number) => string) | null { + const base = (value: number) => formatAxisTick(value, axisNumberFormat); + if (isDistinct(ticks, base)) { + return base; + } + if (!axisNumberFormat) { + // No configured mantissa to escalate - fall back to full, non-compact + // precision, which always distinguishes any two different numbers. + const fullPrecision = (value: number) => + new Intl.NumberFormat('en-US').format(value); + return isDistinct(ticks, fullPrecision) && + fitsLabelBudget(ticks, fullPrecision) + ? fullPrecision + : null; + } + if (axisNumberFormat.output === 'duration') { + // formatDurationMsCompact has no mantissa - escalate its own fixed + // 2-3 significant digits instead, past whichever unit it picks. + const factor = axisNumberFormat.factor ?? 1; + for (let p = 3; p <= 3 + MAX_TICK_MANTISSA_ESCALATION; p++) { + const escalated = (value: number) => + formatDurationMsCompact(value * factor * 1000, p); + if (isDistinct(ticks, escalated) && fitsLabelBudget(ticks, escalated)) { + return escalated; + } + } + return null; + } + // formatAxisTick can force mantissa down to 0 regardless of what's + // configured, so escalation must start from 1, not the configured value. + for (let m = 1; m <= MAX_TICK_MANTISSA_ESCALATION; m++) { + const escalated = (value: number) => + formatTickAtMantissa(value, axisNumberFormat, m); + if (isDistinct(ticks, escalated) && fitsLabelBudget(ticks, escalated)) { + return escalated; + } + } + return null; +} + +export interface NiceYAxisTicks { + ticks: number[]; + tickFormatter?: (value: number) => string; +} + +// Ported from packages/cli/src/termchart/scale.ts's niceTicks: the smallest +// step that fits within [min, max]/maxTicks and formats to distinct labels. +export function getNiceYAxisTicks( + min: number, + max: number, + maxTicks = 5, + axisNumberFormat?: NumberFormat, +): NiceYAxisTicks { + if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) { + return { ticks: [] }; + } + const steps = niceStepsNear((max - min) / (maxTicks - 1), axisNumberFormat); + for (const step of steps) { + const ticks = ticksWithinRange(step, min, max); + // A single tick conveys no scale at all - never accept it, even + // though its "labels" are trivially distinct from one another. + if (!ticks || ticks.length < 2 || ticks.length > maxTicks) { + continue; + } + const tickFormatter = resolveDistinctTickLabels(ticks, axisNumberFormat); + if (tickFormatter) { + return { ticks, tickFormatter }; + } + } + return { ticks: [] }; +} + +export interface ExpandableYAxisTicks { + max: number; + ticks: number[]; + tickFormatter?: (value: number) => string; +} + +// For the plain default branch (domain may expand, as [0,'auto'] did +// pre-PR): rounds the upper bound up to fill slack under maxTicks. +export function getExpandableYAxisTicks( + min: number, + max: number, + maxTicks = 5, + axisNumberFormat?: NumberFormat, +): ExpandableYAxisTicks { + if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) { + return { max, ticks: [] }; + } + const steps = niceStepsNear((max - min) / (maxTicks - 1), axisNumberFormat); + for (const step of steps) { + const tightTicks = ticksWithinRange(step, min, max); + // A single tick conveys no scale at all - never accept it, even + // though its "labels" are trivially distinct from one another. + if (!tightTicks || tightTicks.length < 2 || tightTicks.length > maxTicks) { + continue; + } + const tightFormatter = resolveDistinctTickLabels( + tightTicks, + axisNumberFormat, + ); + if (!tightFormatter) { + continue; + } + const expandedMax = cleanNumber(Math.ceil(max / step) * step); + // Filling a step's worth of dead space is fine, but not at the cost + // of a large fraction of the range - keep the tight result instead. + if (expandedMax - max <= (max - min) * 0.25) { + const expandedTicks = ticksWithinRange(step, min, expandedMax); + if (expandedTicks && expandedTicks.length <= maxTicks) { + const expandedFormatter = resolveDistinctTickLabels( + expandedTicks, + axisNumberFormat, + ); + if (expandedFormatter) { + return { + max: expandedMax, + ticks: expandedTicks, + tickFormatter: expandedFormatter, + }; + } + } + } + return { max, ticks: tightTicks, tickFormatter: tightFormatter }; + } + return { max, ticks: [] }; +} diff --git a/packages/app/src/HDXMultiSeriesTimeChart/chartData.ts b/packages/app/src/HDXMultiSeriesTimeChart/chartData.ts new file mode 100644 index 0000000000..05e74d7e8e --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/chartData.ts @@ -0,0 +1,205 @@ +import { type LineData, MAX_TIME_CHART_SERIES } from '@/ChartUtils'; +export const HARD_LINES_LIMIT = MAX_TIME_CHART_SERIES; + +/** 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 }; +} 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..cc00fc29ad --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/index.ts @@ -0,0 +1,30 @@ +/** + * 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 { + formatAxisTick, + getExpandableYAxisTicks, + getNiceYAxisTicks, + getYAxisTicks, +} from './axisTicks'; +export { + type ActiveClickPayload, + type ActiveClickSeries, + buildActiveClickSeries, + 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'; +export { + computeYAxisBounds, + getLineCurveType, + scanYAxisValueRange, +} from './useChartScales'; diff --git a/packages/app/src/HDXMultiSeriesTimeChart/useChartScales.ts b/packages/app/src/HDXMultiSeriesTimeChart/useChartScales.ts new file mode 100644 index 0000000000..2811535ceb --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTimeChart/useChartScales.ts @@ -0,0 +1,355 @@ +import { useMemo } from 'react'; +import { add, isSameSecond, sub } from 'date-fns'; +import { CurveType } from 'recharts/types/shape/Curve'; +import { AxisDomain } from 'recharts/types/util/types'; +import { convertGranularityToSeconds } from '@hyperdx/common-utils/dist/core/utils'; +import { + DisplayType, + getDefaultLineInterpolation, + LineInterpolation, +} from '@hyperdx/common-utils/dist/types'; + +import { + getSeriesColorForGroup, + type LineData, + toStartOfInterval, +} from '@/ChartUtils'; +import { + ChartAnnotation, + getAnnotationElements, + layoutAnnotations, + resolveAnnotationSeries, +} from '@/components/charts/chartAnnotations'; +import type { NumberFormat } from '@/types'; + +import { + cleanNumber, + formatAxisTick, + getExpandableYAxisTicks, + getNiceYAxisTicks, + getYAxisTicks, +} from './axisTicks'; +import { hasSeriesSelection } from './chartData'; + +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[]; + visibleLineData: LineData[]; + selectedSeriesNames: Set | undefined; + referenceLineValues: number[]; + axisNumberFormat: NumberFormat | undefined; + /** Already scaled for the content font size preference (see MemoChart). */ + yAxisWidth: number; +}; + +// Shared by every yAxisDomain branch below. Callers pass only the series +// actually drawn (already selection- and HARD_LINES_LIMIT-filtered). +export function scanYAxisValueRange( + graphResults: any[], + lineData: LineData[], +): { min: number; max: number } { + let min = Infinity; + let max = -Infinity; + graphResults.forEach(dataPoint => { + lineData.forEach(ld => { + const value = dataPoint[ld.dataKey]; + if (typeof value === 'number' && !isNaN(value)) { + min = Math.min(min, value); + max = Math.max(max, value); + } + }); + }); + return { min, max }; +} + +export interface YAxisBounds { + domain: AxisDomain; + ticks: number[] | undefined; + tickFormatter?: (value: number) => string; +} + +const DEFAULT_Y_AXIS_BOUNDS: YAxisBounds = { + domain: [0, 'auto'], + ticks: undefined, +}; +const FIT_Y_AXIS_BOUNDS: YAxisBounds = { + domain: ['auto', 'auto'], + ticks: undefined, +}; + +const CURVE_TYPE_BY_INTERPOLATION: Record = { + smooth: 'monotone', + linear: 'linear', + step: 'stepAfter', +}; + +export function getLineCurveType( + displayType: DisplayType, + lineInterpolation: LineInterpolation | undefined, +): CurveType { + return CURVE_TYPE_BY_INTERPOLATION[ + lineInterpolation ?? getDefaultLineInterpolation(displayType) + ]; +} + +// A stacked bar's rendered height sums its series at each timestamp - leave +// that entirely to Recharts, regardless of selection/fit-to-data state. Same +// for a stacked line's filled bands. +export function computeYAxisBounds( + graphResults: any[], + visibleLineData: LineData[], + hasSelection: boolean, + fitYAxisToData: boolean, + displayType: DisplayType, + referenceLineValues: number[], + axisNumberFormat?: NumberFormat, +): YAxisBounds { + if ( + displayType === DisplayType.StackedBar || + displayType === DisplayType.StackedLine + ) { + return DEFAULT_Y_AXIS_BOUNDS; + } + const shouldFitYAxis = fitYAxisToData; + + if (!hasSelection && !shouldFitYAxis) { + // A fully numeric domain skips Recharts' own nice rounding, and a + // reference line can extend it further - defer to Recharts entirely. + if (referenceLineValues.length > 0) { + return DEFAULT_Y_AXIS_BOUNDS; + } + const { min, max } = scanYAxisValueRange(graphResults, visibleLineData); + if (max === -Infinity) { + return DEFAULT_Y_AXIS_BOUNDS; + } + // Recharts widens an explicit domain to fit out-of-range data, so + // negative data must be reflected here, not just pinned at zero. + const lowerBound = cleanNumber(Math.min(0, min)); + // max * 1.05 would shrink the upper bound below max for negative data; + // padding away from zero keeps headroom regardless of max's sign. + const upperBound = cleanNumber(max + Math.abs(max) * 0.05); + if (upperBound <= lowerBound) { + return DEFAULT_Y_AXIS_BOUNDS; + } + const expanded = getExpandableYAxisTicks( + lowerBound, + upperBound, + 5, + axisNumberFormat, + ); + // No nice step fits - fall back to getYAxisTicks' reduce-tick-count + // dedup instead of Recharts' raw, collision-prone default. + if (expanded.ticks.length === 0) { + const baseFormat = (value: number) => + formatAxisTick(value, axisNumberFormat); + return { + domain: [lowerBound, upperBound], + ticks: getYAxisTicks(lowerBound, upperBound, baseFormat), + tickFormatter: baseFormat, + }; + } + return { + domain: [lowerBound, expanded.max], + ticks: expanded.ticks, + tickFormatter: expanded.tickFormatter, + }; + } + + // A selection with fit-to-data off still keeps the zero-pinned fallback, + // not the unpinned fit fallback - only fitting itself opts out of it. + const degenerateFallback = shouldFitYAxis + ? FIT_Y_AXIS_BOUNDS + : DEFAULT_Y_AXIS_BOUNDS; + const { min, max } = scanYAxisValueRange(graphResults, visibleLineData); + if (min === Infinity || max === -Infinity) { + return degenerateFallback; + } + const padding = (max - min) * 0.05; + // Recharts widens the domain to actual negative data regardless of fit + // mode, so the lower bound must follow it whenever min itself is negative. + const lowerBound = cleanNumber( + min < 0 ? min - padding : Math.max(0, min - padding), + ); + const upperBound = cleanNumber(max + padding); + if (upperBound <= lowerBound) { + return degenerateFallback; + } + // A reference line can widen the domain (extendDomain) - extend it up + // front and nice-step the result, instead of ticking a stale domain. + if (referenceLineValues.length > 0) { + const extendedLower = cleanNumber( + Math.min(lowerBound, ...referenceLineValues), + ); + const extendedUpper = cleanNumber( + Math.max(upperBound, ...referenceLineValues), + ); + const expanded = getExpandableYAxisTicks( + extendedLower, + extendedUpper, + 5, + axisNumberFormat, + ); + if (expanded.ticks.length > 0) { + return { + domain: [extendedLower, expanded.max], + ticks: expanded.ticks, + tickFormatter: expanded.tickFormatter, + }; + } + const baseFormat = (value: number) => + formatAxisTick(value, axisNumberFormat); + return { + domain: [extendedLower, extendedUpper], + ticks: getYAxisTicks(extendedLower, extendedUpper, baseFormat), + tickFormatter: baseFormat, + }; + } + const { ticks, tickFormatter } = getNiceYAxisTicks( + lowerBound, + upperBound, + 5, + axisNumberFormat, + ); + // Same fallback as the default branch above - reduce tick count via + // getYAxisTicks rather than leaving this to Recharts' raw default. + if (ticks.length === 0) { + const baseFormat = (value: number) => + formatAxisTick(value, axisNumberFormat); + return { + domain: [lowerBound, upperBound], + ticks: getYAxisTicks(lowerBound, upperBound, baseFormat), + tickFormatter: baseFormat, + }; + } + return { + domain: [lowerBound, upperBound], + ticks, + tickFormatter, + }; +} + +/** + * 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, + visibleLineData, + selectedSeriesNames, + referenceLineValues, + axisNumberFormat, + yAxisWidth, +}: UseChartScalesArgs) { + const yAxisBounds = useMemo( + () => + computeYAxisBounds( + graphResults, + visibleLineData, + hasSeriesSelection(selectedSeriesNames), + fitYAxisToData ?? false, + displayType, + referenceLineValues, + axisNumberFormat, + ), + [ + graphResults, + visibleLineData, + selectedSeriesNames, + fitYAxisToData, + displayType, + referenceLineValues, + axisNumberFormat, + ], + ); + + // 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 - yAxisWidth), + }); + }, [coloredAnnotations, xAxisDomain, containerWidth, yAxisWidth]); + + // 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 - yAxisWidth), + }); + }, [coloredAnnotations, xAxisDomain, containerWidth, yAxisWidth]); + + return { + yAxisBounds, + 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..4fd5aca15a --- /dev/null +++ b/packages/app/src/components/DBTimeChart/ChartTooltipOverlay.tsx @@ -0,0 +1,176 @@ +import { useEffect, useRef } from 'react'; +import { NumberFormat } from '@hyperdx/common-utils/dist/types'; +import { Popover, Portal } from '@mantine/core'; + +import { + ChartSeriesTooltip, + type ChartSeriesTooltipProps, +} from '@/components/charts/ChartSeriesTooltip'; +import { useChartTooltipZIndex } from '@/components/charts/ChartTooltip'; +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 +// in MemoChart instead; this is only for the click-locked state. +export function ChartTooltipOverlay({ + payload, + buildSearchUrl, + onDismiss, + onFocusSeries, + getSeriesFilters, + onFilterSeries, + onShowAllSeries, + fallbackNumberFormat, + numberFormatByKey, + previousPeriodOffsetSeconds, + hiddenSeriesCount, + onLoadAllSeries, + expanded, +}: Pick & { + 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" + // 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} + > + + {/* 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 54% rename from packages/app/src/components/DBTimeChart.tsx rename to packages/app/src/components/DBTimeChart/DBTimeChart.tsx index 99e2ff79d6..a592a58e19 100644 --- a/packages/app/src/components/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart/DBTimeChart.tsx @@ -1,54 +1,26 @@ -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 type { AdhocConditionEntry } from '@hyperdx/common-utils/dist/filters'; -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 { - IconChartAreaLine, - IconChartBar, - IconChartLine, -} from '@tabler/icons-react'; import api from '@/api'; import { - AGG_FNS, - buildEventsSearchUrl, - ChartKeyJoiner, convertToTimeChartConfig, formatResponseForTimeChart, getPreviousDateRange, - type LineData, shouldFillNullsWithZero, - tryExpandConfigVariables, useTimeChartSettings, } from '@/ChartUtils'; import { ChartAnnotation } from '@/components/charts/chartAnnotations'; -import { - ChartSeriesTooltip, - type ChartSeriesTooltipProps, -} from '@/components/charts/ChartSeriesTooltip'; -import { useChartTooltipZIndex } from '@/components/charts/ChartTooltip'; +import ChartContainer from '@/components/charts/ChartContainer'; +import ChartErrorState, { + ChartErrorStateVariant, +} from '@/components/charts/ChartErrorState'; import { MAX_LOADABLE_TIME_CHART_SERIES, resolveRenderedSeriesCap, @@ -61,295 +33,17 @@ import { import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation'; import { useResultColumnFilterKeys } from '@/hooks/useResultColumnFilterKeys'; import { useChartNumberFormats, useSource } from '@/source'; -import type { NumberFormat, TileFilterHandler } 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'; - -const PROMQL_REDUCE_CARDINALITY_HINT = - 'Narrow the label matchers, aggregate with sum by (...), or set a series limit to reduce the number of series.'; - -/** 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(); - } - }); -} +import type { TileFilterHandler } from '@/types'; -// 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 ad hoc filter entries that select one chart series: its PromQL labels, - * or a key per group-by column. Undefined when some group column has no key - * or can't be filtered on, since filtering on the rest would match more than - * the series. - */ -export function getSeriesFilterEntries({ - line, - groupColumns, - filterKeyByColumn, - promqlSeriesLabels, -}: { - line: Pick | undefined; - groupColumns: string[]; - filterKeyByColumn: ReadonlyMap; - promqlSeriesLabels?: Record>; -}): AdhocConditionEntry[] | undefined { - if (!line || groupColumns.length === 0) return undefined; - if (promqlSeriesLabels) { - // PromQL series are grouped by their name alone. - const labels = promqlSeriesLabels[String(line.groupValues[0])]; - // An ad hoc filter reaches every PromQL query, so a `__name__` condition - // would empty those on other metrics. - return labels - ? Object.entries(labels) - .filter(([key]) => key !== '__name__') - .map(([key, value]) => ({ key, value })) - : undefined; - } - const entries: AdhocConditionEntry[] = []; - for (const [index, column] of groupColumns.entries()) { - const key = filterKeyByColumn.get(column); - const value = line.groupValues[index]; - // NULL doesn't match `= ''`, and Map / Array / Tuple / JSON values are - // JSON in the series key, which their `toString()` wouldn't match. - if (!key || value == null || typeof value === 'object') return undefined; - entries.push({ key, value: String(value) }); - } - return entries; -} - -// 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, - getSeriesFilters, - onFilterSeries, - onShowAllSeries, - fallbackNumberFormat, - numberFormatByKey, - previousPeriodOffsetSeconds, - hiddenSeriesCount, - onLoadAllSeries, - expanded, -}: Pick & { - 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. */} -
- - - - - - - ); -} +import { ChartTooltipOverlay } from './ChartTooltipOverlay'; +import { useCrossChartPinDismiss } from './crossChartPin'; +import { + buildSeriesSearchUrl, + decodeSeriesGroupFilters, + getSeriesFilterEntries, + type SeriesGroupFilter, +} from './searchUrl'; +import { useChartToolbarItems } from './useChartToolbarItems'; type DBTimeChartComponentProps = { config: ChartConfigWithDateRange; @@ -733,7 +427,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 @@ -787,128 +483,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, @@ -964,121 +550,23 @@ function DBTimeChartComponent({ [lineDataByKey, groupColumns, filterKeyByColumn, promqlSeriesLabels], ); - 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 - ? 'Stacked bar chart unavailable when comparing to previous period' - : 'Display as stacked bar chart', - icon: , - disabled: config.compareToPreviousPeriod, - }, - { - value: DisplayType.StackedLine, - label: config.compareToPreviousPeriod - ? 'Stacked line chart unavailable when comparing to previous period' - : 'Display as stacked line 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 bf460c3493..4e14199a2f 100644 --- a/packages/app/src/components/__tests__/DBTimeChart.test.tsx +++ b/packages/app/src/components/DBTimeChart/__tests__/DBTimeChart.test.tsx @@ -52,11 +52,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__/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 new file mode 100644 index 0000000000..c03ebb3ca0 --- /dev/null +++ b/packages/app/src/components/DBTimeChart/__tests__/searchUrl.test.ts @@ -0,0 +1,214 @@ +import { + type ChartConfigWithDateRange, + SourceKind, + TSource, +} from '@hyperdx/common-utils/dist/types'; + +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 +// 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: TSource = { + id: 'src-1', + kind: SourceKind.Log, + name: 'logs', + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'otel_logs' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Body', +}; + +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; + +const promqlConfig = { + connection: 'conn-1', + configType: 'promql', + promqlExpression: 'up', + 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('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', () => { + 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('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 + // 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..cbe3531e54 --- /dev/null +++ b/packages/app/src/components/DBTimeChart/index.ts @@ -0,0 +1,12 @@ +/** + * 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 { + buildSeriesSearchUrl, + decodeSeriesGroupFilters, + getSeriesFilterEntries, + 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..187e63529f --- /dev/null +++ b/packages/app/src/components/DBTimeChart/searchUrl.ts @@ -0,0 +1,254 @@ +import { add } from 'date-fns'; +import { convertGranularityToSeconds } from '@hyperdx/common-utils/dist/core/utils'; +import type { AdhocConditionEntry } from '@hyperdx/common-utils/dist/filters'; +import { + isBuilderChartConfig, + isPromqlChartConfig, + isRawSqlChartConfig, +} from '@hyperdx/common-utils/dist/guards'; +import { + ChartConfigWithDateRange, + TSource, +} from '@hyperdx/common-utils/dist/types'; + +import { + AGG_FNS, + buildEventsSearchUrl, + ChartKeyJoiner, + type LineData, + 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; +} + +/** + * The ad hoc filter entries that select one chart series: its PromQL labels, + * or a key per group-by column. Undefined when some group column has no key + * or can't be filtered on, since filtering on the rest would match more than + * the series. + */ +export function getSeriesFilterEntries({ + line, + groupColumns, + filterKeyByColumn, + promqlSeriesLabels, +}: { + line: Pick | undefined; + groupColumns: string[]; + filterKeyByColumn: ReadonlyMap; + promqlSeriesLabels?: Record>; +}): AdhocConditionEntry[] | undefined { + if (!line || groupColumns.length === 0) return undefined; + if (promqlSeriesLabels) { + // PromQL series are grouped by their name alone. + const labels = promqlSeriesLabels[String(line.groupValues[0])]; + // An ad hoc filter reaches every PromQL query, so a `__name__` condition + // would empty those on other metrics. + return labels + ? Object.entries(labels) + .filter(([key]) => key !== '__name__') + .map(([key, value]) => ({ key, value })) + : undefined; + } + const entries: AdhocConditionEntry[] = []; + for (const [index, column] of groupColumns.entries()) { + const key = filterKeyByColumn.get(column); + const value = line.groupValues[index]; + // NULL doesn't match `= ''`, and Map / Array / Tuple / JSON values are + // JSON in the series key, which their `toString()` wouldn't match. + if (!key || value == null || typeof value === 'object') return undefined; + entries.push({ key, value: String(value) }); + } + return entries; +} + +/** + * 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; + + // `!= 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 != null && + !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..c115c892dd --- /dev/null +++ b/packages/app/src/components/DBTimeChart/useChartToolbarItems.tsx @@ -0,0 +1,186 @@ +import React, { useMemo } from 'react'; +import { isPromqlChartConfig } from '@hyperdx/common-utils/dist/guards'; +import { + type BuilderChartConfigWithDateRange, + type ChartConfigWithDateRange, + DisplayType, + type TSource, +} from '@hyperdx/common-utils/dist/types'; +import { + IconChartAreaLine, + 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'; +import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation'; + +const PROMQL_REDUCE_CARDINALITY_HINT = + 'Narrow the label matchers, aggregate with sum by (...), or set a series limit to reduce the number of series.'; + +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; + // 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; + 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 + ? 'Stacked bar chart unavailable when comparing to previous period' + : 'Display as stacked bar chart', + icon: , + disabled: config.compareToPreviousPeriod, + }, + { + value: DisplayType.StackedLine, + label: config.compareToPreviousPeriod + ? 'Stacked line chart unavailable when comparing to previous period' + : 'Display as stacked line 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, + ]); +}