From 76bfd32bda5040e656a8e2e328f302c8909ef329 Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Thu, 30 Jul 2026 14:15:35 -0600 Subject: [PATCH 01/12] perf(dashboard): cap high-cardinality time-chart series with load-all escape hatches Time charts with many groups could render thousands of / series at once, spiking memory and causing severe hover jank. Bound the number of series materialized and drawn per tile, and give users ways to see the rest: - A per-tile Series Limit control (0 = unlimited) in Display Settings, defaulting to a safe cap; series beyond the cap are dropped lowest-peak-first and surfaced via a hidden-series notice. - +N more affordances in the hover and pinned tooltips, and a load-all-series action that lifts the cap on demand. - Tooltips cap how many rows they render per frame so a wide bucket cannot mount thousands of popovers; the pinned tooltip scrolls the full set once loaded. - The external dashboards API round-trips the limit as a positive-only limit so a GET -> PUT of a tile with no limit is not rejected by the write schema. --- .../dashboard-time-chart-series-limit.md | 15 + .../external-api/v2/utils/dashboards.ts | 15 +- packages/app/src/ChartUtils.tsx | 181 +++-- packages/app/src/HDXMultiSeriesTimeChart.tsx | 683 ++++++++++++------ packages/app/src/__tests__/ChartUtils.test.ts | 181 +++++ .../__tests__/HDXMultiSeriesTimeChart.test.ts | 148 ++++ .../components/ChartDisplaySettingsDrawer.tsx | 23 +- .../app/src/components/ChartEditor/utils.ts | 7 + .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 4 + .../__tests__/utils.test.ts | 2 - packages/app/src/components/DBTimeChart.tsx | 118 ++- .../ChartDisplaySettingsDrawer.test.tsx | 12 +- .../components/__tests__/DBTimeChart.test.tsx | 180 +++++ .../components/charts/ChartSeriesTooltip.tsx | 82 ++- .../charts/HiddenSeriesIndicator.tsx | 53 ++ .../__tests__/ChartSeriesTooltip.test.tsx | 133 ++++ .../__tests__/HiddenSeriesIndicator.test.tsx | 45 ++ packages/app/src/defaults.ts | 45 ++ .../hooks/__tests__/useChartConfig.test.tsx | 39 + packages/app/src/hooks/useChartConfig.tsx | 29 +- packages/app/styles/HDXLineChart.module.scss | 29 + .../src/__tests__/renderChartConfig.test.ts | 12 + .../common-utils/src/__tests__/utils.test.ts | 18 + .../src/core/renderChartConfig.ts | 5 +- packages/common-utils/src/core/utils.ts | 23 +- packages/common-utils/src/types.ts | 20 +- 26 files changed, 1803 insertions(+), 299 deletions(-) create mode 100644 .changeset/dashboard-time-chart-series-limit.md create mode 100644 packages/app/src/components/charts/HiddenSeriesIndicator.tsx create mode 100644 packages/app/src/components/charts/__tests__/ChartSeriesTooltip.test.tsx create mode 100644 packages/app/src/components/charts/__tests__/HiddenSeriesIndicator.test.tsx diff --git a/.changeset/dashboard-time-chart-series-limit.md b/.changeset/dashboard-time-chart-series-limit.md new file mode 100644 index 0000000000..13b08c5f0e --- /dev/null +++ b/.changeset/dashboard-time-chart-series-limit.md @@ -0,0 +1,15 @@ +--- +'@hyperdx/common-utils': minor +'@hyperdx/app': minor +'@hyperdx/api': minor +--- + +Cap high-cardinality time-chart series to protect the browser from rendering +thousands of lines at once. Time charts now materialize and draw a bounded +number of series per tile, with escape hatches to reveal the rest on demand: +a "+N more" affordance in the hover and pinned tooltips, and a "load all +series" action that lifts the cap for a chart. Tooltips also cap how many rows +they render per frame so a wide bucket can't mount thousands of popovers. The +external dashboards API round-trips the per-tile series limit as a positive-only +`limit`, so a GET → PUT of a tile with no limit is no longer rejected by the +write schema. diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index 638efbcb1b..fc8ecf9821 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -1,6 +1,7 @@ import { displayTypeSupportsBuilderAlerts, displayTypeSupportsRawSqlAlerts, + hasPositiveSeriesLimit, } from '@hyperdx/common-utils/dist/core/utils'; import { validateDashboardContainersStructure, @@ -352,7 +353,12 @@ const convertToExternalTileChartConfig = ( groupBy: stringValueOrDefault(config.groupBy, undefined), orderBy: stringValueOrDefault(config.orderBy, undefined), numberFormat: config.numberFormat, - limit: config.seriesLimit ?? undefined, + // 0 = unlimited internally, but the external `limit` is positive-only; + // emit it as absent so a GET->PUT round-trip isn't rejected by the + // write-body schema. null/undefined also map to absent. + limit: hasPositiveSeriesLimit(config.seriesLimit) + ? config.seriesLimit + : undefined, }; case DisplayType.Bar: return { @@ -364,7 +370,12 @@ const convertToExternalTileChartConfig = ( groupBy: stringValueOrDefault(config.groupBy, undefined), orderBy: stringValueOrDefault(config.orderBy, undefined), numberFormat: config.numberFormat, - limit: config.seriesLimit ?? undefined, + // 0 = unlimited internally, but the external `limit` is positive-only; + // emit it as absent so a GET->PUT round-trip isn't rejected by the + // write-body schema. null/undefined also map to absent. + limit: hasPositiveSeriesLimit(config.seriesLimit) + ? config.seriesLimit + : undefined, }; case DisplayType.Table: return { diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index d9e2ad8a70..780008d336 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -18,6 +18,7 @@ import { convertToTableChartConfig, getAlignedDateRange, Granularity, + hasPositiveSeriesLimit, } from '@hyperdx/common-utils/dist/core/utils'; import { isBuilderChartConfig } from '@hyperdx/common-utils/dist/guards'; import { @@ -38,7 +39,10 @@ import { notifications } from '@mantine/notifications'; import DateRangeIndicator from './components/charts/DateRangeIndicator'; import { MVOptimizationExplanationResult } from './hooks/useMVOptimizationExplanation'; -import { DEFAULT_SERIES_LIMIT } from './defaults'; +import { + DEFAULT_SERIES_LIMIT, + MAX_RENDERED_TIME_CHART_SERIES, +} from './defaults'; import { getMetricNameSql } from './otelSemanticConventions'; import { AggFn, TableChartSeries, TimeChartSeries } from './types'; import { NumberFormat } from './types'; @@ -110,13 +114,15 @@ export const MAX_TIME_CHART_SERIES = DEFAULT_SERIES_LIMIT; export function convertToTimeChartConfig( config: ChartConfigWithDateRange, ): ChartConfigWithDateRange { - // Series capping is opt-in per tile via the chart's Display Settings; when - // unset, no __hdx_series_limit CTE is emitted and every series is fetched. - const seriesLimit = isBuilderChartConfig(config) - ? config.seriesLimit != null - ? Math.max(1, config.seriesLimit) - : undefined - : undefined; + // Builder group-by charts emit the __hdx_series_limit CTE only for a positive + // seriesLimit. null/undefined (default) and 0 (explicitly unlimited) both + // skip the CTE and fetch every series; the client-side render cap in + // formatResponseForTimeChart then applies the default/opt-out behavior + // (mirrors resolveRenderedSeriesCap on the SQL side). + const seriesLimit = + isBuilderChartConfig(config) && hasPositiveSeriesLimit(config.seriesLimit) + ? config.seriesLimit + : undefined; const granularity = getTimeChartGranularity( config.granularity, @@ -611,55 +617,83 @@ function addResponseToFormattedData({ const isSingleValueColumn = valueColumns.length === 1; const hasGroupColumns = groupColumns.length > 0; - for (const row of data) { - const date = new Date(row[timestampColumn.name]); + // Hoist per-row-loop invariants: this runs once per row × value column, + // hundreds of thousands of times on a high-cardinality group-by. + const groupColumnNames = groupColumns.map(g => g.name); + const valueColumnNames = valueColumns.map(v => v.name); + // Single value column + group-by simplifies the key to just the group. + const omitValueColumnInKey = isSingleValueColumn && hasGroupColumns; + const applyLogLevelColor = firstGroupColumnIsLogLevel(source, groupColumns); + const timestampColumnName = timestampColumn.name; + const offsetSeconds = isPreviousPeriod ? previousPeriodOffsetSeconds : 0; + + // A time chart has very few distinct bucket timestamps (one per granularity + // step) but potentially hundreds of thousands of rows, so `new Date(...)` + // parsing per row dominated the transform. Cache the parsed epoch-second + // bucket per raw timestamp value — same input always yields the same result, + // so this is behavior-preserving regardless of the value's format. + const tsSecondsByRaw = new Map(); - // Previous period data needs to be shifted forward to align with current period - const offsetSeconds = isPreviousPeriod ? previousPeriodOffsetSeconds : 0; - const ts = Math.round(date.getTime() / 1000 + offsetSeconds); + for (const row of data) { + const rawTs = row[timestampColumnName]; + let ts = tsSecondsByRaw.get(rawTs); + if (ts === undefined) { + ts = Math.round(new Date(rawTs).getTime() / 1000 + offsetSeconds); + tsSecondsByRaw.set(rawTs, ts); + } - for (const valueColumn of valueColumns) { - let tsBucket = tsBucketMap.get(ts); - if (tsBucket == null) { - tsBucket = { [timestampColumn.name]: ts }; - tsBucketMap.set(ts, tsBucket); - } + let tsBucket = tsBucketMap.get(ts); + if (tsBucket == null) { + tsBucket = { [timestampColumnName]: ts }; + tsBucketMap.set(ts, tsBucket); + } - const currentPeriodKey = [ - // Simplify the display name if there's only one series and a group by - ...(isSingleValueColumn && hasGroupColumns ? [] : [valueColumn.name]), - ...groupColumns.map(g => { - const v = row[g.name]; - return typeof v === 'object' && v !== null ? JSON.stringify(v) : v; - }), - ].join(ChartKeyJoiner); - const previousPeriodKey = `${currentPeriodKey}${PreviousPeriodSuffix}`; - const keyName = isPreviousPeriod ? previousPeriodKey : currentPeriodKey; + // Group key parts, built once per row and shared across value columns. + // Array.join renders null/undefined as '' (matches the prior behavior). + const groupKeyParts = groupColumnNames.map(name => { + const v = row[name]; + return typeof v === 'object' && v !== null ? JSON.stringify(v) : v; + }); + const groupKeyPart = groupKeyParts.join(ChartKeyJoiner); + + for (const valueColumnName of valueColumnNames) { + const currentPeriodKey = omitValueColumnInKey + ? groupKeyPart + : hasGroupColumns + ? [valueColumnName, ...groupKeyParts].join(ChartKeyJoiner) + : valueColumnName; + const keyName = isPreviousPeriod + ? `${currentPeriodKey}${PreviousPeriodSuffix}` + : currentPeriodKey; // UInt64 are returned as strings, we'll convert to number // and accept a bit of floating point error - const rawValue = row[valueColumn.name]; + const rawValue = row[valueColumnName]; const value = typeof rawValue === 'number' ? rawValue : Number.parseFloat(rawValue); // Mutate the existing bucket object to avoid repeated large object copies tsBucket[keyName] = value; - // Special handling for log level / trace severity colors - let color: string | undefined = undefined; - if (firstGroupColumnIsLogLevel(source, groupColumns)) { - color = logLevelColor(row[groupColumns[0].name]); + // Build the LineData entry once per key (not once per row): the object + // churn was the dominant cost on high-cardinality group-bys. Only the + // log-level color is row-dependent, so refresh just that on later rows. + const existing = lineDataMap[keyName]; + if (existing == null) { + lineDataMap[keyName] = { + dataKey: keyName, + currentPeriodKey, + previousPeriodKey: `${currentPeriodKey}${PreviousPeriodSuffix}`, + displayName: keyName, + valueColumnName, + color: applyLogLevelColor + ? logLevelColor(row[groupColumnNames[0]]) + : undefined, + isDashed: isPreviousPeriod, + }; + } else if (applyLogLevelColor) { + existing.color = logLevelColor(row[groupColumnNames[0]]); } - - lineDataMap[keyName] = { - dataKey: keyName, - currentPeriodKey, - previousPeriodKey, - displayName: keyName, - valueColumnName: valueColumn.name, - color, - isDashed: isPreviousPeriod, - }; } } } @@ -675,6 +709,7 @@ export function formatResponseForTimeChart({ source, hiddenSeries = [], previousPeriodOffsetSeconds = 0, + maxSeries = MAX_RENDERED_TIME_CHART_SERIES, }: { dateRange: [Date, Date]; granularity?: SQLInterval; @@ -684,6 +719,12 @@ export function formatResponseForTimeChart({ source?: TSource; hiddenSeries?: string[]; previousPeriodOffsetSeconds?: number; + /** + * Render cap for the number of series. Defaults to + * MAX_RENDERED_TIME_CHART_SERIES; pass Number.POSITIVE_INFINITY to render + * every series (the "load all" escape hatch behind the hidden-series notice). + */ + maxSeries?: number; }) { const meta = currentPeriodResponse.meta; @@ -737,13 +778,62 @@ export function formatResponseForTimeChart({ } const logLevelColorOrder = getLogLevelColorOrder(); - const sortedLineData = Object.values(lineDataMap).sort((a, b) => { + let sortedLineData = Object.values(lineDataMap).sort((a, b) => { return ( logLevelColorOrder.findIndex(color => color === a.color) - logLevelColorOrder.findIndex(color => color === b.color) ); }); + // Cap materialized series to protect browser memory: high-cardinality + // group-bys (esp. raw SQL, which has no server-side limit) can return tens of + // thousands of series while only a handful are drawn. Keep the top `maxSeries` + // by peak value; drop and count the rest. + let hiddenSeriesCount = 0; + if (sortedLineData.length > maxSeries) { + hiddenSeriesCount = sortedLineData.length - maxSeries; + + // Peak absolute value per series across all buckets (single pass). + const peakByKey = new Map(); + for (const tsBucket of tsBucketMap.values()) { + for (const line of sortedLineData) { + const raw = tsBucket[line.dataKey]; + if (typeof raw === 'number' && Number.isFinite(raw)) { + const mag = Math.abs(raw); + const prev = peakByKey.get(line.dataKey); + if (prev == null || mag > prev) { + peakByKey.set(line.dataKey, mag); + } + } + } + } + + // Sort by peak desc; index tiebreak keeps the log-level color ordering. + const keptKeys = new Set( + sortedLineData + .map((line, index) => ({ line, index })) + .sort((a, b) => { + const diff = + (peakByKey.get(b.line.dataKey) ?? 0) - + (peakByKey.get(a.line.dataKey) ?? 0); + return diff !== 0 ? diff : a.index - b.index; + }) + .slice(0, maxSeries) + .map(({ line }) => line.dataKey), + ); + + sortedLineData = sortedLineData.filter(line => keptKeys.has(line.dataKey)); + + // Prune dropped keys from every bucket so graphResults stays small. + for (const tsBucket of tsBucketMap.values()) { + for (const key of Object.keys(tsBucket)) { + if (key !== timestampColumn.name && !keptKeys.has(key)) { + delete tsBucket[key]; + } + } + } + } + if (generateEmptyBuckets && granularity != null) { const generatedTsBuckets = timeBucketByGranularity( dateRange[0], @@ -792,6 +882,7 @@ export function formatResponseForTimeChart({ groupColumns: groupColumns.map(g => g.name), valueColumns: valueColumns.map(v => v.name), isSingleValueColumn, + hiddenSeriesCount, }; } diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index b334956ecb..4688ec3691 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -58,6 +58,12 @@ 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. @@ -209,41 +215,59 @@ const HDXLineChartTooltip = withErrorBoundary( } : {}; + // 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 (
- {/* Copy before sorting: Recharts 3 freezes the payload, so an - in-place sort throws "this object has been frozen". */} - {[...payload] - .sort((a: TooltipPayload, b: TooltipPayload) => b.value - a.value) - .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; + {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 ( - - ); - })} + return ( + + ); + })} + {hiddenRowCount > 0 && ( +
+ +{hiddenRowCount.toLocaleString()} more +
+ )}
); @@ -462,6 +486,20 @@ export type ActiveClickPayload = { /** 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; @@ -481,7 +519,11 @@ export function buildActiveClickSeries( if (activeRow == null) return []; return visibleLineData.flatMap(ld => { const value = activeRow[ld.dataKey]; - if (typeof value !== 'number') return []; + // 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. @@ -504,6 +546,35 @@ export function buildActiveClickSeries( }); } +/** + * 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 @@ -517,14 +588,61 @@ export function buildActiveClickSeries( 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 = !!selectedSeriesNames && selectedSeriesNames.size > 0; if (hasSelection) { - return lineData - .filter(ld => selectedSeriesNames.has(getSeriesDisplayName(ld))) - .slice(0, HARD_LINES_LIMIT); + 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 lineData.slice(0, HARD_LINES_LIMIT); + return { rows: visible, hiddenCount: rows.length - visible.length }; } const StackedBarWithOverlap = (props: BarProps) => { @@ -630,6 +748,7 @@ export function collectMemoChartGradientHexes( export const MemoChart = memo(function MemoChart({ graphResults, setIsClickActive, + refreshClickActive, isClickActive, dateRange, lineData, @@ -653,6 +772,12 @@ export const MemoChart = memo(function MemoChart({ }: { 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[]; @@ -725,16 +850,16 @@ export const MemoChart = memo(function MemoChart({ [lineData, selectedSeriesNames], ); - const lines = useMemo(() => { - // When a series is nearest the cursor (only meaningful with more than one - // line shown), thicken its line and fade the others so the eye lands on - // the same series the tooltip bolds. Mirrors the legend's selected style - // (thicker stroke) with a gentle fade that keeps the rest readable. - const hasNearest = - visibleLineData.length > 1 && - nearestSeriesKey != null && - visibleLineData.some(ld => ld.dataKey === nearestSeriesKey); + // Series for the pinned tooltip's drill-down list: selection applied but NOT + // clamped to HARD_LINES_LIMIT, so "load all series" reveals series that were + // materialized (up to the render cap) yet not drawn. Kept separate from + // visibleLineData so the drawn chart stays bounded at HARD_LINES_LIMIT. + const tooltipLineData = useMemo( + () => getSelectedLineData(lineData, selectedSeriesNames), + [lineData, selectedSeriesNames], + ); + const lines = useMemo(() => { return visibleLineData.map(ld => { const key = ld.dataKey; const color = ld.color; @@ -760,31 +885,40 @@ export const MemoChart = memo(function MemoChart({ type="monotone" stroke={color} fillOpacity={1} - strokeWidth={hasNearest && key === nearestSeriesKey ? 2.5 : undefined} - strokeOpacity={ - hasNearest && key !== nearestSeriesKey ? 0.5 : undefined - } + // Stable per-series class so the nearest-cursor emphasis can be + // applied via CSS (see nearestSeriesStyle) rather than by changing + // these props — a prop change here rebuilds every on hover. + className={seriesClassName(id, key)} activeDot={} - {...(isHovered - ? { fill: 'none', strokeDasharray } - : { - fill: `url(#time-chart-lin-grad-${id}-${color?.replace('#', '').toLowerCase()})`, - strokeDasharray, - })} + // Fill is always the gradient. Hiding it on hover is a CSS class + // toggle (styles.chartHovered), not a prop swap — swapping it here + // re-created every on each hover enter/leave (hover churn). + fill={`url(#time-chart-lin-grad-${id}-${color?.replace('#', '').toLowerCase()})`} + strokeDasharray={strokeDasharray} name={seriesName} isAnimationActive={false} connectNulls /> ); }); - }, [ - visibleLineData, - displayType, - id, - isHovered, - nearestSeriesKey, - captureActivePointY, - ]); + }, [visibleLineData, displayType, id, captureActivePointY]); + + // Nearest-cursor emphasis (thicken the nearest line, fade the rest) applied + // via a tiny scoped + ); + }, [nearestSeriesKey, visibleLineData.length, id]); const yAxisDomain: AxisDomain = useMemo(() => { const hasSelection = selectedSeriesNames && selectedSeriesNames.size > 0; @@ -874,7 +1008,9 @@ export const MemoChart = memo(function MemoChart({ const activeRow = graphResults.find( row => String(row[timestampKey]) === activeLabel, ); - const activePayload = buildActiveClickSeries(visibleLineData, activeRow); + // 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; } @@ -887,9 +1023,44 @@ export const MemoChart = memo(function MemoChart({ activePayload, }; }, - [graphResults, timestampKey, visibleLineData], + [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 @@ -999,6 +1170,216 @@ export const MemoChart = memo(function MemoChart({ 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 xAxisDomain: AxisDomain = useMemo(() => { let startTime = toStartOfInterval(dateRange[0], granularity); let endTime = toStartOfInterval(dateRange[1], granularity); @@ -1037,8 +1418,24 @@ export const MemoChart = memo(function MemoChart({ return (
's fill prop, so the chart's ~N Area + // elements are not re-created on every hover enter/leave. + // `rr-block` tells the HyperDX/rrweb session-replay recorder to capture + // this chart as a placeholder rather than serializing its (very large) + // SVG DOM on every mutation — the dominant session-replay cost on + // high-cardinality dashboards. + className={cx( + 'rr-block', + styles.chartRoot, + isHovered && styles.chartHovered, + )} + // Scopes nearestSeriesStyle to this chart instance. + data-chart-id={id} style={{ position: 'relative', width: '100%', height: '100%' }} > + {nearestSeriesStyle} {onTimeRangeSelect != null && zoomOrigin != null ? (
)} diff --git a/packages/app/src/components/DBEditTimeChartForm/__tests__/utils.test.ts b/packages/app/src/components/DBEditTimeChartForm/__tests__/utils.test.ts index bce2bd0b35..971bdf2d1b 100644 --- a/packages/app/src/components/DBEditTimeChartForm/__tests__/utils.test.ts +++ b/packages/app/src/components/DBEditTimeChartForm/__tests__/utils.test.ts @@ -427,7 +427,6 @@ describe('buildChartConfigForExplanations', () => { }); expect(result).toBeDefined(); - // @ts-expect-error union types.. expect(result!.seriesLimit).toBe(3); }); @@ -442,7 +441,6 @@ describe('buildChartConfigForExplanations', () => { }); expect(result).toBeDefined(); - // @ts-expect-error union types.. expect(result!.seriesLimit).toBeUndefined(); }); diff --git a/packages/app/src/components/DBTimeChart.tsx b/packages/app/src/components/DBTimeChart.tsx index 28111d3b84..c115f46439 100644 --- a/packages/app/src/components/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart.tsx @@ -39,6 +39,10 @@ import { import { ChartAnnotation } from '@/components/charts/chartAnnotations'; import { ChartSeriesTooltip } from '@/components/charts/ChartSeriesTooltip'; import { useChartTooltipZIndex } from '@/components/charts/ChartTooltip'; +import { + MAX_LOADABLE_TIME_CHART_SERIES, + resolveRenderedSeriesCap, +} from '@/defaults'; import { type ActiveClickPayload, MemoChart } from '@/HDXMultiSeriesTimeChart'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation'; @@ -51,6 +55,7 @@ import ChartErrorState, { } from './charts/ChartErrorState'; import DateRangeIndicator from './charts/DateRangeIndicator'; import DisplaySwitcher from './charts/DisplaySwitcher'; +import HiddenSeriesIndicator from './charts/HiddenSeriesIndicator'; import MVOptimizationIndicator from './MaterializedViews/MVOptimizationIndicator'; /** A single group column / value pair decoded from a chart series key. */ @@ -138,6 +143,9 @@ function ChartTooltipOverlay({ fallbackNumberFormat, numberFormatByKey, previousPeriodOffsetSeconds, + hiddenSeriesCount, + onLoadAllSeries, + expanded, }: { payload: ActiveClickPayload | undefined; buildSearchUrl: (key?: string, value?: number) => string | null; @@ -148,6 +156,12 @@ function ChartTooltipOverlay({ /** 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 && @@ -261,6 +275,9 @@ function ChartTooltipOverlay({ buildSearchUrl={buildSearchUrl} onDismiss={onDismiss} onFocusSeries={onFocusSeries} + hiddenSeriesCount={hiddenSeriesCount} + onLoadAllSeries={onLoadAllSeries} + expanded={expanded} /> @@ -332,6 +349,10 @@ function DBTimeChartComponent({ new Set(), ); + // When the render cap hides series, the hidden-series notice lets the user + // opt into rendering every series (accepting the memory/perf cost). + const [showAllSeries, setShowAllSeries] = useState(false); + const handleToggleSeries = useCallback( (seriesName: string, isShiftKey?: boolean) => { setSelectedSeriesSet(prev => { @@ -377,12 +398,34 @@ function DBTimeChartComponent({ [config], ); - // Determine whether the config can be optimized with an MV, to determine whether - // to show the MV optimization indicator and date range indicator in the toolbar + // Stable identity for the query's SHAPE, excluding the sliding time window. + // `queriedConfig` (and the `config` it derives from) is a fresh object literal + // on every render — dashboard tiles rebuild the tile config inline each render + // (e.g. on hover) and live ranges tick the dateRange/granularity — so keying + // effects on its object reference, or serializing the whole thing, would fire + // them on unrelated re-renders / every live tick. Stripping the time fields + // yields a value that changes only when the user re-authors the query. + const queryShapeIdentity = useMemo(() => { + // Serialize every top-level field except the sliding time window. + const shape: Record = { ...queriedConfig }; + delete shape.dateRange; + delete shape.granularity; + delete shape.dateRangeEndInclusive; + return JSON.stringify(shape); + }, [queriedConfig]); + + // Determine whether the config can be optimized with an MV, to drive the MV + // optimization indicator and the MV-derived date-range indicator in the + // toolbar. Only those two indicators consume `mvOptimizationData`, so skip + // this extra ClickHouse EXPLAIN when both are hidden — which includes the + // edit-modal preview (ChartPreviewPanel passes showMVOptimizationIndicator and + // showDateRangeIndicator both false), so the EXPLAIN is skipped there too. const builderQueriedConfig: BuilderChartConfigWithDateRange | undefined = isBuilderChartConfig(queriedConfig) ? queriedConfig : undefined; - const { data: mvOptimizationData } = - useMVOptimizationExplanation(builderQueriedConfig); + const { data: mvOptimizationData } = useMVOptimizationExplanation( + builderQueriedConfig, + { enabled: showMVOptimizationIndicator || showDateRangeIndicator }, + ); const { data, isLoading, isError, error, isPlaceholderData, isSuccess } = useQueriedChartConfig(queriedConfig, { @@ -461,6 +504,7 @@ function DBTimeChartComponent({ valueColumns, isSingleValueColumn, lineData, + hiddenSeriesCount, } = useMemo(() => { const defaultResponse = { error: null, @@ -470,6 +514,7 @@ function DBTimeChartComponent({ groupColumns: [], valueColumns: [], isSingleValueColumn: true, + hiddenSeriesCount: 0, }; if (data == null || !isSuccess) { @@ -488,6 +533,16 @@ function DBTimeChartComponent({ source, hiddenSeries, previousPeriodOffsetSeconds, + // "Load all" (from the warning / pinned tooltip) overrides everything; + // otherwise the per-tile Series Limit drives the cap (null = default, + // 0 = unlimited). On builder group-by charts the SQL CTE already trims + // to seriesLimit, so this is a no-op there; on raw SQL it's the only + // cardinality guard. "Load all" is bounded (not truly unlimited) so a + // runaway high-cardinality result can't exhaust browser memory; drawn + // lines stay capped at HARD_LINES_LIMIT either way. + maxSeries: showAllSeries + ? MAX_LOADABLE_TIME_CHART_SERIES + : resolveRenderedSeriesCap(config.seriesLimit), }); return { ...defaultResponse, @@ -508,9 +563,11 @@ function DBTimeChartComponent({ fillNulls, source, config.compareToPreviousPeriod, + config.seriesLimit, previousPeriodData, hiddenSeries, previousPeriodOffsetSeconds, + showAllSeries, ]); // To enable backward compatibility, allow non-controlled usage of displayType @@ -548,6 +605,22 @@ function DBTimeChartComponent({ const dismissPinned = useCallback(() => setActiveClickPayload(undefined), []); const notifyTooltipPinned = useCrossChartPinDismiss(dismissPinned); + // Reset the "load all" opt-in whenever the query shape changes. Dashboard + // tiles key on chart.id (not config) and the edit-modal preview has no key, so + // the component stays mounted across config edits; without this a stale + // showAllSeries=true would bypass the newly-authored series cap on the next + // query. Keyed on `queryShapeIdentity` (a stable serialization of the query + // shape) rather than the `queriedConfig` object reference — which is new every + // render — so unrelated re-renders (tile hover) and live-range ticks don't + // reset the opt-in, while re-authoring the group-by / filter / series limit + // still does. Also dismiss any open pin: its frozen snapshot belongs to the + // previous query, and the resync effect would otherwise repaint it with the + // new query's series at the stale click anchor. + useEffect(() => { + setShowAllSeries(false); + dismissPinned(); + }, [queryShapeIdentity, dismissPinned]); + // Pin the tooltip on click. Not gated on `source`: source-less charts still // show values/percent-change, and the drill-down actions hide themselves when // there's no source. `disableDrillDown` stays an explicit opt-out. @@ -565,6 +638,20 @@ function DBTimeChartComponent({ [disableDrillDown, notifyTooltipPinned], ); + // In-place refresh of the already-open pin's frozen snapshot (used by the + // chart's resync effect after "load all" / live ticks). Unlike + // setPinnedPayload this does NOT broadcast the cross-chart pin-dismiss — it + // isn't opening a new pin, just repainting the current one's rows. + const refreshPinnedPayload = useCallback( + (payload: ActiveClickPayload | undefined) => { + if (disableDrillDown) { + return; + } + setActiveClickPayload(payload); + }, + [disableDrillDown], + ); + const clickedActiveLabelDate = useMemo(() => { return activeClickPayload?.activeLabel != null ? new Date(Number.parseInt(activeClickPayload.activeLabel) * 1000) @@ -770,6 +857,17 @@ function DBTimeChartComponent({ ); } + if (hiddenSeriesCount > 0) { + allToolbarItems.push( + setShowAllSeries(true)} + />, + ); + } + if (toolbarSuffix && toolbarSuffix.length > 0) { allToolbarItems.push(...toolbarSuffix); } @@ -788,6 +886,8 @@ function DBTimeChartComponent({ showDateRangeIndicator, mvOptimizationData, queriedConfig, + hiddenSeriesCount, + lineData.length, ]); return ( @@ -824,6 +924,15 @@ function DBTimeChartComponent({ fallbackNumberFormat={queriedConfig.numberFormat} numberFormatByKey={formatByColumn} previousPeriodOffsetSeconds={previousPeriodOffsetSeconds} + // "+N more" in the pinned tooltip loads every series (same escape + // hatch as the hidden-series warning). Only offered while capped. + hiddenSeriesCount={hiddenSeriesCount} + onLoadAllSeries={ + showAllSeries ? undefined : () => setShowAllSeries(true) + } + // Once loaded, render the full set in the scrollable tooltip body + // (not just the 20-row preview) so "load all" actually shows them. + expanded={showAllSeries} /> { ).toBeInTheDocument(); }); - it('does not show the Series Limit input for raw SQL line charts', () => { + it('shows the Series Limit input for raw SQL line charts (client render cap)', () => { renderWithMantine( { />, ); - expect( - screen.queryByRole('textbox', { name: /series limit/i }), - ).not.toBeInTheDocument(); + const input = screen.getByRole('textbox', { name: /series limit/i }); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute( + 'placeholder', + `Default (${MAX_RENDERED_TIME_CHART_SERIES})`, + ); }); it('does not show the Series Limit input for table charts', () => { diff --git a/packages/app/src/components/__tests__/DBTimeChart.test.tsx b/packages/app/src/components/__tests__/DBTimeChart.test.tsx index 1e86e1e60d..4e7052c335 100644 --- a/packages/app/src/components/__tests__/DBTimeChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTimeChart.test.tsx @@ -1,4 +1,8 @@ import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import { Notifications } from '@mantine/notifications'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import api from '@/api'; import { ChartKeyJoiner } from '@/ChartUtils'; @@ -177,6 +181,182 @@ describe('DBTimeChart', () => { expect(mvOptExplanationConfig).toBe(indicatorConfig); }); + it('disables the MV-optimization query when both MV and date-range indicators are hidden', () => { + jest.mocked(useSource).mockReturnValue({ + data: { id: 'test-source', name: 'Test Source' }, + } as any); + + renderWithMantine( + , + ); + + expect(jest.mocked(useMVOptimizationExplanation)).toHaveBeenCalled(); + const options = jest.mocked(useMVOptimizationExplanation).mock.calls[0][1]; + expect(options?.enabled).toBe(false); + }); + + it('keeps the MV-optimization query enabled when only the date-range indicator is shown', () => { + jest.mocked(useSource).mockReturnValue({ + data: { id: 'test-source', name: 'Test Source' }, + } as any); + + renderWithMantine( + , + ); + + const options = jest.mocked(useMVOptimizationExplanation).mock.calls[0][1]; + expect(options?.enabled).toBe(true); + }); + + describe('load-all series escape hatch', () => { + // A high-cardinality group-by response: MAX_RENDERED_TIME_CHART_SERIES (250) + // default cap + 50 extra groups, so 50 series are hidden and the + // HiddenSeriesIndicator surfaces the load-all affordance. + const HIDDEN = 50; + const GROUP_COUNT = 250 + HIDDEN; + + const highCardinalityData = Array.from({ length: GROUP_COUNT }, (_, i) => ({ + timestamp: 1704067200, + value: i + 1, + group: `g${i}`, + })); + const highCardinalityMeta = [ + { name: 'timestamp', type: 'DateTime' }, + { name: 'value', type: 'UInt64' }, + { name: 'group', type: 'String' }, + ]; + + const groupByConfig = { + ...baseTestConfig, + groupBy: 'group', + }; + + beforeEach(() => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { + data: highCardinalityData, + meta: highCardinalityMeta, + rows: GROUP_COUNT, + isComplete: true, + }, + isLoading: false, + isError: false, + isSuccess: true, + isPlaceholderData: false, + }); + }); + + it('surfaces the load-all button when the render cap hides series, then hides it after loading all', async () => { + const user = userEvent.setup(); + renderWithMantine(); + + // 50 series over the default 250 cap => the load-all affordance appears. + const loadAllButton = await screen.findByRole('button', { + name: /load all .* series/i, + }); + expect(loadAllButton).toBeInTheDocument(); + + await user.click(loadAllButton); + + // After loading all, every series is materialized (bounded, but far above + // GROUP_COUNT), so nothing is hidden and the affordance disappears. + await waitFor(() => { + expect( + screen.queryByRole('button', { name: /load all .* series/i }), + ).not.toBeInTheDocument(); + }); + }); + + it('keeps the load-all opt-in across an unrelated re-render with a fresh-but-equal config', async () => { + // Regression for the reset effect firing on every render: dashboard tiles + // pass a fresh config object literal each render (e.g. on hover), so a + // reset keyed on config/queriedConfig identity would snap showAllSeries + // back to false and re-cap the chart. The opt-in must survive a re-render + // whose config is a new object with identical query shape. + const user = userEvent.setup(); + const { rerender } = renderWithMantine( + , + ); + + const loadAllButton = await screen.findByRole('button', { + name: /load all .* series/i, + }); + await user.click(loadAllButton); + await waitFor(() => { + expect( + screen.queryByRole('button', { name: /load all .* series/i }), + ).not.toBeInTheDocument(); + }); + + // Re-render with a brand-new object that is structurally identical (mimics + // the dashboard rebuilding the tile config inline on an unrelated render). + rerender( + + + + , + ); + + // The opt-in survives: no series are re-hidden, so the affordance stays + // gone. (Before the fix, the reset effect fired here and it reappeared.) + expect( + screen.queryByRole('button', { name: /load all .* series/i }), + ).not.toBeInTheDocument(); + }); + + it('resets the load-all opt-in when the query shape changes (e.g. seriesLimit re-authored)', async () => { + // The reset must still fire for a genuine query change: after loading all, + // re-authoring the tile (here, tightening seriesLimit) re-applies the cap. + const user = userEvent.setup(); + const { rerender } = renderWithMantine( + , + ); + + const loadAllButton = await screen.findByRole('button', { + name: /load all .* series/i, + }); + await user.click(loadAllButton); + await waitFor(() => { + expect( + screen.queryByRole('button', { name: /load all .* series/i }), + ).not.toBeInTheDocument(); + }); + + // Change the query shape (a positive seriesLimit below GROUP_COUNT keeps + // series hidden), which should reset the opt-in and re-show the affordance. + rerender( + + + + , + ); + + expect( + await screen.findByRole('button', { name: /load all .* series/i }), + ).toBeInTheDocument(); + }); + + it('does not hide series (no load-all affordance) when seriesLimit is 0 (unlimited)', () => { + renderWithMantine( + , + ); + + // seriesLimit=0 resolves to an unlimited render cap, so no series are + // dropped and the load-all affordance never appears. + expect( + screen.queryByRole('button', { name: /load all .* series/i }), + ).not.toBeInTheDocument(); + }); + }); + it('renders DateRangeIndicator when MV optimization returns a different date range', () => { const originalStartDate = new Date('2024-01-01T00:00:30Z'); const originalEndDate = new Date('2024-01-01T01:30:45Z'); diff --git a/packages/app/src/components/charts/ChartSeriesTooltip.tsx b/packages/app/src/components/charts/ChartSeriesTooltip.tsx index 489d869fb1..a20e3d524b 100644 --- a/packages/app/src/components/charts/ChartSeriesTooltip.tsx +++ b/packages/app/src/components/charts/ChartSeriesTooltip.tsx @@ -1,5 +1,12 @@ import Link from 'next/link'; -import { ActionIcon, Group, Stack, Text, Tooltip } from '@mantine/core'; +import { + ActionIcon, + Group, + Stack, + Text, + Tooltip, + UnstyledButton, +} from '@mantine/core'; import { useClipboard } from '@mantine/hooks'; import { IconCheck, @@ -8,7 +15,12 @@ import { IconSearch, } from '@tabler/icons-react'; +import { MAX_EXPANDED_TOOLTIP_ROWS } from '@/defaults'; import type { ActiveClickSeries } from '@/HDXMultiSeriesTimeChart'; +import { + getVisibleTooltipRows, + MAX_TOOLTIP_ROWS, +} from '@/HDXMultiSeriesTimeChart'; import type { NumberFormat } from '@/types'; import { @@ -150,6 +162,36 @@ export type ChartSeriesTooltipProps = { onDismiss?: () => void; /** Focus a series by its raw key + display name. */ onFocusSeries?: (payload: { dataKey?: string; name: string }) => void; + /** + * Series dropped by the chart's render cap (i.e. absent from activePayload + * entirely, not just beyond this tooltip's row cap). Added to the tooltip's + * own overflow to size the "+N more" affordance against the true total. + */ + hiddenSeriesCount?: number; + /** + * Render every series on the chart, bypassing the cap. When provided, the + * "+N more" line becomes a button that triggers it (the same escape hatch as + * the chart's hidden-series warning). Omit to keep "+N more" passive. + */ + onLoadAllSeries?: () => void; + /** + * When true, the caller has already loaded all series ("load all" is active), + * so the tooltip renders EVERY row (up to `expandedRowCap`) in its scrollable + * body instead of clamping to MAX_TOOLTIP_ROWS. This is what makes the pinned + * tooltip's "load all" actually reveal the extra rows: the container + * (.chartTooltipContent) already scrolls, so lifting the render cap lets the + * user scroll through the full set. Kept bounded by `expandedRowCap` so a + * runaway high-cardinality bucket can't mount thousands of row Tooltips. + */ + expanded?: boolean; + /** + * Upper bound on rows rendered when `expanded`. Defaults to + * MAX_EXPANDED_TOOLTIP_ROWS. Each row mounts several Mantine Tooltips, so this + * is deliberately well below the chart's materialize ceiling — enough to make + * "load all" meaningfully bigger than the 20-row preview without mounting + * thousands of popovers. Series beyond it stay counted in the "+N more" line. + */ + expandedRowCap?: number; }; /** @@ -167,6 +209,10 @@ export function ChartSeriesTooltip({ buildSearchUrl, onDismiss, onFocusSeries, + hiddenSeriesCount = 0, + onLoadAllSeries, + expanded = false, + expandedRowCap = MAX_EXPANDED_TOOLTIP_ROWS, }: ChartSeriesTooltipProps) { // Exclude previous-period series from the row list; their comparison is // folded into the matching current-period row as a percent-change chip. @@ -180,6 +226,20 @@ export function ChartSeriesTooltip({ return null; } + // Cap rendered rows (each mounts a Mantine Tooltip); rows is value-desc, so + // this keeps the largest. No cursor concept here — the pin is frozen. Once + // "load all" is active (`expanded`), render every row (up to expandedRowCap) + // so the user can scroll the full set instead of being stuck at the 20-row + // preview; the scrollable container bounds the height either way. + const rowCap = expanded ? expandedRowCap : MAX_TOOLTIP_ROWS; + const { rows: visibleRows, hiddenCount: tooltipHiddenCount } = + getVisibleTooltipRows(rows, undefined, rowCap); + + // Rows not shown = those beyond this tooltip's cap PLUS series the chart's + // render cap dropped entirely (absent from activePayload). Clicking loads all + // series onto the chart — the same escape hatch as the hidden-series warning. + const totalHidden = tooltipHiddenCount + hiddenSeriesCount; + // Per-series actions only make sense with more than one group (a single series // is covered by the header/footer). const showPerSeriesActions = rows.length > 1; @@ -218,8 +278,9 @@ export function ChartSeriesTooltip({ return ( - - {rows.map((payload, idx) => { + {/* Height bounded by the shared .chartTooltipContent container. */} + + {visibleRows.map((payload, idx) => { const name = payload.name ?? payload.dataKey ?? ''; const rowNumberFormat = (payload.valueColumnName != null @@ -259,6 +320,21 @@ export function ChartSeriesTooltip({ /> ); })} + {totalHidden > 0 && + (onLoadAllSeries ? ( + + + +{totalHidden.toLocaleString()} more (click to load all) + + + ) : ( + + +{totalHidden.toLocaleString()} more + + ))} ); diff --git a/packages/app/src/components/charts/HiddenSeriesIndicator.tsx b/packages/app/src/components/charts/HiddenSeriesIndicator.tsx new file mode 100644 index 0000000000..3ec4c95b33 --- /dev/null +++ b/packages/app/src/components/charts/HiddenSeriesIndicator.tsx @@ -0,0 +1,53 @@ +import { Tooltip, UnstyledButton } from '@mantine/core'; +import { IconAlertTriangle } from '@tabler/icons-react'; + +interface HiddenSeriesIndicatorProps { + hiddenSeriesCount: number; + renderedSeriesCount: number; + /** Render every series, bypassing the cap. Omit to keep the notice passive. */ + onLoadAll?: () => void; +} + +/** + * Warns that the chart returned more series than the client renders. The + * transform caps series to protect memory; this surfaces the dropped ones and, + * when `onLoadAll` is provided, lets the user render all of them anyway. + */ +export default function HiddenSeriesIndicator({ + hiddenSeriesCount, + renderedSeriesCount, + onLoadAll, +}: HiddenSeriesIndicatorProps) { + if (hiddenSeriesCount <= 0) { + return null; + } + + const total = renderedSeriesCount + hiddenSeriesCount; + const label = + `This query returned ${total.toLocaleString()} series. ` + + `${hiddenSeriesCount.toLocaleString()} low-value series were hidden to keep the page responsive; ` + + `showing the top ${renderedSeriesCount.toLocaleString()} by peak value.` + + (onLoadAll + ? ` Click to load all ${total.toLocaleString()} (may be slow).` + : ' Add a stricter GROUP BY, a WHERE filter, or a series limit to reduce cardinality.'); + + const icon = ( + + ); + + return ( + + {onLoadAll ? ( + + {icon} + + ) : ( + icon + )} + + ); +} diff --git a/packages/app/src/components/charts/__tests__/ChartSeriesTooltip.test.tsx b/packages/app/src/components/charts/__tests__/ChartSeriesTooltip.test.tsx new file mode 100644 index 0000000000..86cca03d3e --- /dev/null +++ b/packages/app/src/components/charts/__tests__/ChartSeriesTooltip.test.tsx @@ -0,0 +1,133 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ChartSeriesTooltip } from '@/components/charts/ChartSeriesTooltip'; +import type { ActiveClickSeries } from '@/HDXMultiSeriesTimeChart'; +import { MAX_TOOLTIP_ROWS } from '@/HDXMultiSeriesTimeChart'; + +// Build `count` current-period series with descending values so the tooltip's +// value-desc sort/cap is deterministic. +function makeRows(count: number): ActiveClickSeries[] { + return Array.from({ length: count }, (_, i) => ({ + value: count - i, + dataKey: `g${i}`, + name: `g${i}`, + color: '#437eef', + })); +} + +const baseProps = { + activeLabel: '1700000000', + numberFormatByKey: new Map(), + buildSearchUrl: () => null, +}; + +describe('ChartSeriesTooltip', () => { + it('shows passive "+N more" text (not a button) without onLoadAllSeries', () => { + // 25 rows over the 20-row cap => 5 hidden by the tooltip; plus 100 dropped + // by the chart render cap => totalHidden should read 105. + renderWithMantine( + , + ); + + expect(screen.queryByRole('button', { name: /load all/i })).toBeNull(); + // tooltipHiddenCount (5) + hiddenSeriesCount (100) = 105. + expect(screen.getByText(/\+105 more/)).toBeInTheDocument(); + }); + + it('renders a clickable load-all button and fires onLoadAllSeries', async () => { + const onLoadAllSeries = jest.fn(); + renderWithMantine( + , + ); + + const button = screen.getByRole('button', { + name: /load all 105 more series/i, + }); + expect(button).toHaveTextContent(/\+105 more \(click to load all\)/); + await userEvent.click(button); + expect(onLoadAllSeries).toHaveBeenCalledTimes(1); + }); + + it('folds hiddenSeriesCount into the total even when nothing overflows the tooltip cap', () => { + // Under the tooltip cap (no tooltipHiddenCount), so the "+N more" total is + // driven entirely by the chart-level render cap. + renderWithMantine( + , + ); + + expect(screen.getByText(/\+42 more/)).toBeInTheDocument(); + }); + + it('shows no "+N more" line when nothing is hidden', () => { + renderWithMantine( + , + ); + + expect(screen.queryByText(/more/)).toBeNull(); + }); + + it('caps rendered rows at MAX_TOOLTIP_ROWS when not expanded', () => { + renderWithMantine( + , + ); + + // Only the top 20 series render; the 21st (g20) is beyond the preview cap. + expect(screen.getByText('g0')).toBeInTheDocument(); + expect(screen.getByText(`g${MAX_TOOLTIP_ROWS - 1}`)).toBeInTheDocument(); + expect(screen.queryByText(`g${MAX_TOOLTIP_ROWS}`)).toBeNull(); + // The overflow is summarized. + expect(screen.getByText(/\+30 more/)).toBeInTheDocument(); + }); + + it('renders every row (past the 20-row preview) when expanded, so "load all" reveals them', () => { + // This is the core fix: once "load all" is active the pinned tooltip shows + // the full set in its scrollable body instead of the 20-row preview. + const count = MAX_TOOLTIP_ROWS + 30; + renderWithMantine( + , + ); + + // Rows beyond the 20-preview are now present. + expect(screen.getByText(`g${MAX_TOOLTIP_ROWS}`)).toBeInTheDocument(); + expect(screen.getByText(`g${count - 1}`)).toBeInTheDocument(); + // Nothing beyond the expanded cap here, so no "+N more". + expect(screen.queryByText(/more/)).toBeNull(); + }); + + it('still summarizes rows beyond expandedRowCap when expanded', () => { + renderWithMantine( + , + ); + + // 30 rows, cap 25 => 5 summarized. + expect(screen.getByText('g24')).toBeInTheDocument(); + expect(screen.queryByText('g25')).toBeNull(); + expect(screen.getByText(/\+5 more/)).toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/components/charts/__tests__/HiddenSeriesIndicator.test.tsx b/packages/app/src/components/charts/__tests__/HiddenSeriesIndicator.test.tsx new file mode 100644 index 0000000000..672fe13828 --- /dev/null +++ b/packages/app/src/components/charts/__tests__/HiddenSeriesIndicator.test.tsx @@ -0,0 +1,45 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import HiddenSeriesIndicator from '@/components/charts/HiddenSeriesIndicator'; + +describe('HiddenSeriesIndicator', () => { + it('renders nothing when no series are hidden', () => { + renderWithMantine( + , + ); + // No warning icon/button when nothing is hidden. + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('is a passive icon (not a button) without onLoadAll', () => { + renderWithMantine( + , + ); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders a clickable load-all button and fires onLoadAll', async () => { + const onLoadAll = jest.fn(); + renderWithMantine( + , + ); + // aria-label reflects the total (rendered + hidden). + const button = screen.getByRole('button', { + name: /load all 1,000 series/i, + }); + await userEvent.click(button); + expect(onLoadAll).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/defaults.ts b/packages/app/src/defaults.ts index 95d151307f..45a62091b1 100644 --- a/packages/app/src/defaults.ts +++ b/packages/app/src/defaults.ts @@ -6,6 +6,51 @@ export const DEFAULT_QUERY_TIMEOUT = 60; // max_execution_time, seconds export const DEFAULT_FILTER_KEYS_FETCH_LIMIT = 100; export const DEFAULT_SERIES_LIMIT = 100; +// Default ceiling on distinct series the time-chart transform materializes, +// across all config types, when a tile has no explicit `seriesLimit`. +// High-cardinality group-bys (esp. raw SQL) can return tens of thousands of +// series; without this cap the client holds them all in memory while only +// DEFAULT_SERIES_LIMIT are drawn. Series beyond the effective cap are dropped +// (lowest peak first) and surfaced via a hidden-series notice. Override +// per-tile via the Display Settings "Series Limit" control. +export const MAX_RENDERED_TIME_CHART_SERIES = 250; + +// Upper bound on rows the pinned tooltip renders once "load all series" is +// active. The tooltip body scrolls, but each row mounts several Mantine +// Tooltips (Search/Copy/Focus), so we don't render the full materialized set +// (up to MAX_LOADABLE_TIME_CHART_SERIES) — that could mount thousands of +// popovers and hang the tab. This ceiling is far above the 20-row preview +// (MAX_TOOLTIP_ROWS) and the drawn-line cap (HARD_LINES_LIMIT = 100), so "load +// all" reveals a meaningfully larger, scrollable list; series beyond it remain +// summarized in the tooltip's "+N more" line. +export const MAX_EXPANDED_TOOLTIP_ROWS = 500; + +// Hard ceiling for the "load all series" escape hatch. Clicking the +// hidden-series notice (or the pinned tooltip's "+N more") opts into rendering +// beyond the default cap, but we still bound materialization so a runaway +// high-cardinality raw-SQL result (tens of thousands of series) can't exhaust +// browser memory / hang the tab. 5000 is a generous ceiling far above both the +// default materialize cap (MAX_RENDERED_TIME_CHART_SERIES) and the draw cap +// (HARD_LINES_LIMIT); drawn lines remain bounded by HARD_LINES_LIMIT regardless. +export const MAX_LOADABLE_TIME_CHART_SERIES = 5000; + +/** + * Resolve the effective client-side render cap from a tile's `seriesLimit` + * (see SharedChartSettingsSchema): null/undefined → the default cap, 0 → + * unlimited (Infinity), a positive N → N. + */ +export function resolveRenderedSeriesCap( + seriesLimit: number | null | undefined, +): number { + if (seriesLimit == null) { + return MAX_RENDERED_TIME_CHART_SERIES; + } + if (seriesLimit <= 0) { + return Number.POSITIVE_INFINITY; + } + return seriesLimit; +} + export function searchChartConfigDefaults( team: any | undefined | null, ): Partial { diff --git a/packages/app/src/hooks/__tests__/useChartConfig.test.tsx b/packages/app/src/hooks/__tests__/useChartConfig.test.tsx index 84651d07d4..7b9eab0b7c 100644 --- a/packages/app/src/hooks/__tests__/useChartConfig.test.tsx +++ b/packages/app/src/hooks/__tests__/useChartConfig.test.tsx @@ -12,6 +12,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { useClickhouseClient } from '@/clickhouse'; import { + appendChunk, getGranularityAlignedTimeWindows, useQueriedChartConfig, } from '@/hooks/useChartConfig'; @@ -1524,4 +1525,42 @@ describe('useChartConfig', () => { expect(result2.current.data?.data).toBeDefined(); }); }); + + describe('appendChunk', () => { + const empty = { data: [], meta: [], rows: 0, isComplete: false }; + + it('reuses the chunk array on the first/only chunk (no copy)', () => { + const chunkData = [{ a: 1 }, { a: 2 }]; + const chunk = { + data: chunkData, + meta: [{ name: 'a', type: 'UInt64' }], + rows: 2, + }; + const result = appendChunk(empty, { chunk, isComplete: true }); + // Same array reference — the large-array spread copy is skipped. + expect(result.data).toBe(chunkData); + expect(result.rows).toBe(2); + expect(result.isComplete).toBe(true); + expect(result.meta).toBe(chunk.meta); + }); + + it('prepends the newer chunk ahead of accumulated rows on later chunks', () => { + const older = { + data: [{ a: 3 }], + meta: [{ name: 'a', type: 'UInt64' }], + rows: 1, + isComplete: false, + }; + const chunk = { + data: [{ a: 1 }, { a: 2 }], + meta: [{ name: 'a', type: 'UInt64' }], + rows: 2, + }; + const result = appendChunk(older, { chunk, isComplete: true }); + // Newer chunk first, then accumulated (oldest-first ordering preserved). + expect(result.data).toEqual([{ a: 1 }, { a: 2 }, { a: 3 }]); + expect(result.data).not.toBe(chunk.data); // fresh array when merging + expect(result.rows).toBe(3); + }); + }); }); diff --git a/packages/app/src/hooks/useChartConfig.tsx b/packages/app/src/hooks/useChartConfig.tsx index e4393dc780..22fbd0b556 100644 --- a/packages/app/src/hooks/useChartConfig.tsx +++ b/packages/app/src/hooks/useChartConfig.tsx @@ -11,7 +11,10 @@ import { isUsingGranularity, renderChartConfig, } from '@hyperdx/common-utils/dist/core/renderChartConfig'; -import { convertDateRangeToGranularityString } from '@hyperdx/common-utils/dist/core/utils'; +import { + convertDateRangeToGranularityString, + hasPositiveSeriesLimit, +} from '@hyperdx/common-utils/dist/core/utils'; import { isBuilderChartConfig, isPromqlChartConfig, @@ -160,9 +163,12 @@ async function* fetchDataInChunks({ // are picked by recent activity, so groups with no events in the newest // window are dropped from the chart. const rankingDateRange = windows[0]?.dateRange; - const seriesLimit = isBuilderChartConfig(config) - ? config.seriesLimit - : undefined; + // Only a positive seriesLimit emits the __hdx_series_limit CTE (0 = unlimited, + // null = default), so only then does the ranking need a pinned date range. + const seriesLimit = + isBuilderChartConfig(config) && hasPositiveSeriesLimit(config.seriesLimit) + ? config.seriesLimit + : undefined; const windowedConfigFor = (w: (typeof windows)[number]) => ({ ...config, ...(w ?? {}), @@ -244,13 +250,22 @@ async function* fetchDataInChunks({ } } -/** Append the given chunk to the given accumulated result */ -function appendChunk( +/** Append the given chunk to the given accumulated result. Exported for tests. */ +export function appendChunk( accumulated: TQueryFnData, { chunk, isComplete }: TChunk, ): TQueryFnData { + const chunkData = chunk.data || []; + const accumulatedData = accumulated?.data || []; + // Fast path for the first/only chunk (always the case for raw SQL, which is + // never chunked): reuse the chunk's array instead of spreading it into a new + // one. Avoids an O(rows) copy of a potentially very large (100k+) row array. + const data = + accumulatedData.length === 0 + ? chunkData + : [...chunkData, ...accumulatedData]; return { - data: [...(chunk.data || []), ...(accumulated?.data || [])], + data, meta: chunk.meta, rows: (accumulated?.rows || 0) + (chunk.rows || 0), isComplete, diff --git a/packages/app/styles/HDXLineChart.module.scss b/packages/app/styles/HDXLineChart.module.scss index 071ecb1b74..538735eca5 100644 --- a/packages/app/styles/HDXLineChart.module.scss +++ b/packages/app/styles/HDXLineChart.module.scss @@ -1,3 +1,18 @@ +/* stylelint-disable selector-pseudo-class-no-unknown */ + +// Chart root wrapper. Also the scope for the nearest-series emphasis