diff --git a/.changeset/timechart-display-settings.md b/.changeset/timechart-display-settings.md new file mode 100644 index 0000000000..e0f6ab089d --- /dev/null +++ b/.changeset/timechart-display-settings.md @@ -0,0 +1,18 @@ +--- +'@hyperdx/app': patch +'@hyperdx/common-utils': patch +--- + +feat: Per-tile display settings for time charts + +Line and area time charts gain three settings in the tile Display Settings +drawer, persisted on the tile config: + +- Show Legend: hide or show the series legend. +- Hover Tooltip: Auto, Single series, All series, or Hidden. Auto keeps the + density-based behavior (a single-series tooltip on dense charts, the full + list otherwise); the other values pin the mode. +- Line Style: Linear, Smooth, or Step interpolation for the drawn series. + +All three are optional and fall back to the current behavior (legend on, Auto +tooltip, Smooth line) when unset, so existing tiles are unchanged. diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 42fb376f70..dc0d880ad3 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -66,6 +66,15 @@ const NEAREST_SERIES_MAX_DISTANCE_PX = 30; type TooltipMode = 'single' | 'all' | 'hidden'; +// The persisted hover-tooltip setting from the tile config. 'auto' defers to +// the density-based default (resolveTooltipMode); the other values are the +// explicit TooltipMode overrides. +export type TooltipModeSetting = 'auto' | TooltipMode; + +// Curve interpolation for the drawn series, matching Recharts' `type`. Persisted +// on the tile config; defaults to 'monotone' (smooth) when unset. +export type LineInterpolation = 'linear' | 'monotone' | 'step'; + // Above this many visible series the hover tooltip collapses to just the series // under the cursor: a list of dozens of rows is unreadable and never the one // being pointed at. At or below it, the full sorted list stays useful. @@ -192,6 +201,22 @@ export function resolveTooltipMode( : 'all'; } +/** + * The tooltip mode a chart actually renders with, folding the persisted tile + * setting over the density-based default. An explicit setting ('single' / + * 'all' / 'hidden') always wins; 'auto' (or an unset setting) falls back to + * `resolveTooltipMode`, so a tile keeps its adaptive behavior unless the user + * pins a mode in Display Settings. + */ +export function resolveEffectiveTooltipMode( + setting: TooltipModeSetting | undefined, + displayType: DisplayType | undefined, + visibleSeriesCount: number, +): TooltipMode { + if (setting != null && setting !== 'auto') return setting; + return resolveTooltipMode(displayType, visibleSeriesCount); +} + /** * Which rows the hover tooltip renders, given its mode and hover state. Returns * `null` to render nothing (a synced follower with no matching series, where @@ -826,6 +851,8 @@ export const MemoChart = memo(function MemoChart({ granularity, dateRangeEndInclusive = true, fitYAxisToData = false, + tooltipMode: tooltipModeSetting, + lineInterpolation = 'monotone', }: { graphResults: any[]; setIsClickActive: (v: ActiveClickPayload | undefined) => void; @@ -859,6 +886,13 @@ export const MemoChart = memo(function MemoChart({ * (with padding) instead of zero. **/ fitYAxisToData?: boolean; + /** + * Persisted hover-tooltip setting from the tile config. 'auto' (or unset) + * derives the mode from the chart's density; the explicit values override it. + */ + tooltipMode?: TooltipModeSetting; + /** Curve interpolation for the drawn series. Defaults to 'monotone' (smooth). */ + lineInterpolation?: LineInterpolation; }) { const _id = useId(); const id = _id.replace(/:/g, ''); @@ -928,8 +962,14 @@ export const MemoChart = memo(function MemoChart({ // Dense line/area charts collapse the hover tooltip to the series under the // cursor; small charts and stacked bars keep the full list. Derived from the - // drawn series so legend filtering restores the full tooltip. - const tooltipMode = resolveTooltipMode(displayType, visibleLineData.length); + // drawn series so legend filtering restores the full tooltip. A tile can pin + // an explicit mode via Display Settings (tooltipModeSetting), which overrides + // this density-based default. + const tooltipMode = resolveEffectiveTooltipMode( + tooltipModeSetting, + displayType, + visibleLineData.length, + ); // The series to emphasize: the line nearest the cursor. Cheap to recompute; // drives the overlay + the dim-others CSS class only, never the base lines. @@ -974,7 +1014,7 @@ export const MemoChart = memo(function MemoChart({ } @@ -990,7 +1030,14 @@ export const MemoChart = memo(function MemoChart({ /> ); }); - }, [visibleLineData, displayType, id, isHovered, captureActivePointY]); + }, [ + visibleLineData, + displayType, + id, + isHovered, + captureActivePointY, + lineInterpolation, + ]); // The emphasized series redrawn thick and on top. recharts paints graphical // items in mount order and ignores a reorder of existing children, so an @@ -1008,7 +1055,7 @@ export const MemoChart = memo(function MemoChart({ key="__hdx_emphasis_overlay__" className="hdx-emphasis-overlay" dataKey={emphasizedKey} - type="monotone" + type={lineInterpolation} stroke={ld.color} strokeWidth={2.5} strokeOpacity={1} @@ -1021,7 +1068,7 @@ export const MemoChart = memo(function MemoChart({ name={getSeriesDisplayName(ld)} /> ); - }, [emphasizedKey, visibleLineData, displayType]); + }, [emphasizedKey, visibleLineData, displayType, lineInterpolation]); const yAxisDomain: AxisDomain = useMemo(() => { const hasSelection = selectedSeriesNames && selectedSeriesNames.size > 0; diff --git a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts index 2c79afeeaf..a96eaba94d 100644 --- a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts +++ b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts @@ -14,6 +14,7 @@ import { collectMemoChartGradientHexes, getVisibleLineData, HARD_LINES_LIMIT, + resolveEffectiveTooltipMode, resolveTooltipMode, selectTooltipRows, } from '@/HDXMultiSeriesTimeChart'; @@ -397,3 +398,52 @@ describe('resolveTooltipMode', () => { expect(resolveTooltipMode(DisplayType.Line, 3)).toBe('all'); }); }); + +// A tile can pin an explicit tooltip mode in Display Settings, which overrides +// the density-based default. 'auto' (or an unset setting) keeps the adaptive +// behavior. Pins that precedence so the two never get crossed. +describe('resolveEffectiveTooltipMode', () => { + it('honors an explicit mode over the density default', () => { + // Sparse chart would auto-resolve to 'all'; an explicit 'single' wins. + expect(resolveEffectiveTooltipMode('single', DisplayType.Line, 2)).toBe( + 'single', + ); + // Dense chart would auto-resolve to 'single'; an explicit 'all' wins. + expect(resolveEffectiveTooltipMode('all', DisplayType.Line, 50)).toBe( + 'all', + ); + // 'hidden' has no auto equivalent; it only comes from an explicit setting. + expect(resolveEffectiveTooltipMode('hidden', DisplayType.Line, 2)).toBe( + 'hidden', + ); + }); + + it("falls back to the density default when the setting is 'auto'", () => { + expect(resolveEffectiveTooltipMode('auto', DisplayType.Line, 50)).toBe( + 'single', + ); + expect(resolveEffectiveTooltipMode('auto', DisplayType.Line, 2)).toBe( + 'all', + ); + }); + + it('falls back to the density default when the setting is unset', () => { + expect(resolveEffectiveTooltipMode(undefined, DisplayType.Line, 50)).toBe( + 'single', + ); + expect(resolveEffectiveTooltipMode(undefined, DisplayType.Line, 2)).toBe( + 'all', + ); + }); + + it('still forces the full tooltip for stacked bars under auto', () => { + // Stacked bars ignore the density collapse; auto must keep that. + expect( + resolveEffectiveTooltipMode('auto', DisplayType.StackedBar, 50), + ).toBe('all'); + // But an explicit 'hidden' setting still overrides even a stacked bar. + expect( + resolveEffectiveTooltipMode('hidden', DisplayType.StackedBar, 50), + ).toBe('hidden'); + }); +}); diff --git a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx index ba38dac8d1..db5d15f35e 100644 --- a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx +++ b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx @@ -14,6 +14,7 @@ import { Drawer, Group, NumberInput, + SegmentedControl, Stack, Text, } from '@mantine/core'; @@ -32,6 +33,7 @@ import { import { ColorSwatchInput } from './ColorSwatchInput'; import { CheckBoxControlled } from './InputControlled'; import { DEFAULT_NUMBER_FORMAT, NumberFormatForm } from './NumberFormat'; +import SelectControlled from './SelectControlled'; export type ChartConfigDisplaySettings = Pick< ChartConfigWithDateRange, @@ -43,6 +45,9 @@ export type ChartConfigDisplaySettings = Pick< | 'color' | 'colorRules' | 'backgroundChart' + | 'showLegend' + | 'tooltipMode' + | 'lineInterpolation' > & { groupByColumnsOnLeft?: boolean; alternateRowBackground?: boolean; @@ -102,6 +107,11 @@ function applyDefaultSettings( ? attachLocalIds(settings.colorRules) : undefined, backgroundChart: settings.backgroundChart, + // Line/area time-chart controls. Defaults match the renderer's fallbacks: + // legend on, tooltip mode auto (density-driven), smooth interpolation. + showLegend: settings.showLegend ?? true, + tooltipMode: settings.tooltipMode ?? 'auto', + lineInterpolation: settings.lineInterpolation ?? 'monotone', }; } @@ -181,6 +191,12 @@ export default function ChartDisplaySettingsDrawer({ const isTimeChart = displayType === DisplayType.Line || displayType === DisplayType.StackedBar; + // Legend / tooltip / line-style controls apply to line/area time charts + // (DisplayType.Line renders as an area chart). Stacked bars keep the full + // tooltip and have no line curve, so this section is Line-only, mirroring the + // other displayType-gated sections below. + const showLineChartOptions = displayType === DisplayType.Line; + // The series-limit CTE is only emitted for builder group-by time charts; // raw SQL configs author their own LIMIT logic directly. const showSeriesLimit = @@ -288,6 +304,57 @@ export default function ChartDisplaySettingsDrawer({ )} + {showLineChartOptions && ( + <> + + + + + + + Line Style + + ( + + )} + /> + + + + )} + {showCategoricalLimit && ( <> diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index 625a61025a..6edda705fd 100644 --- a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx @@ -276,6 +276,9 @@ export default function EditTimeChartForm({ color, colorRules, backgroundChart, + showLegend, + tooltipMode, + lineInterpolation, ] = useWatch({ control, name: [ @@ -290,6 +293,9 @@ export default function EditTimeChartForm({ 'color', 'colorRules', 'backgroundChart', + 'showLegend', + 'tooltipMode', + 'lineInterpolation', ], }); @@ -320,6 +326,9 @@ export default function EditTimeChartForm({ color, colorRules, backgroundChart, + showLegend, + tooltipMode, + lineInterpolation, }), [ alignDateRangeToGranularity, @@ -333,6 +342,9 @@ export default function EditTimeChartForm({ color, colorRules, backgroundChart, + showLegend, + tooltipMode, + lineInterpolation, ], ); @@ -633,6 +645,9 @@ export default function EditTimeChartForm({ color, colorRules, backgroundChart, + showLegend, + tooltipMode, + lineInterpolation, }: ChartConfigDisplaySettings, isDirty: boolean, ) => { @@ -655,6 +670,9 @@ export default function EditTimeChartForm({ setValue('color', color); setValue('colorRules', colorRules); setValue('backgroundChart', backgroundChart); + setValue('showLegend', showLegend); + setValue('tooltipMode', tooltipMode); + setValue('lineInterpolation', lineInterpolation); // Display settings live in a separate drawer form, so RHF can't track // them. Latch dirty state only when the drawer reports actual changes. if (isDirty) { diff --git a/packages/app/src/components/DBTimeChart.tsx b/packages/app/src/components/DBTimeChart.tsx index 28111d3b84..0e39ce18d1 100644 --- a/packages/app/src/components/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart.tsx @@ -840,7 +840,11 @@ function DBTimeChartComponent({ referenceLines={referenceLines} annotations={annotations} setIsClickActive={setPinnedPayload} - showLegend={showLegend} + // The tile config can hide the legend via Display Settings. Combine + // it with the context prop (AND) so a surface that never shows a + // legend (e.g. compact search charts passing showLegend={false}) + // still wins, and existing tiles (config unset) are unchanged. + showLegend={showLegend && (queriedConfig.showLegend ?? true)} timestampKey={timestampColumn?.name} previousPeriodOffsetSeconds={previousPeriodOffsetSeconds} selectedSeriesNames={selectedSeriesSet} @@ -848,6 +852,8 @@ function DBTimeChartComponent({ granularity={granularity} dateRangeEndInclusive={queriedConfig.dateRangeEndInclusive} fitYAxisToData={queriedConfig.fitYAxisToData} + tooltipMode={queriedConfig.tooltipMode} + lineInterpolation={queriedConfig.lineInterpolation} /> )} diff --git a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx index e07419a2b5..fb7a2f3e02 100644 --- a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx +++ b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx @@ -563,4 +563,153 @@ describe('ChartDisplaySettingsDrawer', () => { }); }); }); + + describe('line/area display settings', () => { + // Mantine's Select scrolls the active option into view on open; jsdom has + // no scrollIntoView, so stub it for the dropdown interaction. + beforeAll(() => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + }); + + it('shows the legend / tooltip / line-style controls for line charts', () => { + renderWithMantine( + , + ); + + expect( + screen.getByRole('checkbox', { name: /show legend/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('combobox', { name: /hover tooltip/i }), + ).toBeInTheDocument(); + expect(screen.getByText('Line Style')).toBeInTheDocument(); + expect( + screen.getByRole('radio', { name: /smooth/i }), + ).toBeInTheDocument(); + }); + + it('does not show the controls for stacked bar charts', () => { + renderWithMantine( + , + ); + + expect( + screen.queryByRole('checkbox', { name: /show legend/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByText('Line Style')).not.toBeInTheDocument(); + }); + + it('does not show the controls for table charts', () => { + renderWithMantine( + , + ); + + expect( + screen.queryByRole('checkbox', { name: /show legend/i }), + ).not.toBeInTheDocument(); + }); + + it('defaults to legend on, tooltip auto, smooth interpolation', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + expect( + screen.getByRole('checkbox', { name: /show legend/i }), + ).toBeChecked(); + + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0]).toMatchObject({ + showLegend: true, + tooltipMode: 'auto', + lineInterpolation: 'monotone', + }); + }); + + it('emits showLegend = false when unchecked and applied', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + await user.click(screen.getByRole('checkbox', { name: /show legend/i })); + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(onChange.mock.calls[0][0]).toMatchObject({ showLegend: false }); + }); + + it('emits the chosen line interpolation when applied', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + await user.click(screen.getByText('Step')); + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(onChange.mock.calls[0][0]).toMatchObject({ + lineInterpolation: 'step', + }); + }); + + it('reflects a persisted tooltip mode and preserves it on apply', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + // The control shows the persisted value (the Select input renders the + // option's label as its value). + expect( + screen.getByRole('combobox', { name: /hover tooltip/i }), + ).toHaveValue('Single series'); + + // Applying without touching it keeps the value. The changed-value path is + // the same `...rest` spread proven by the Show Legend and Line Style + // cases above; the override semantics are unit-tested in + // resolveEffectiveTooltipMode. + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(onChange.mock.calls[0][0]).toMatchObject({ + tooltipMode: 'single', + }); + }); + }); }); diff --git a/packages/common-utils/src/__tests__/types.test.ts b/packages/common-utils/src/__tests__/types.test.ts index 978f809f3d..e1c0a9bb2f 100644 --- a/packages/common-utils/src/__tests__/types.test.ts +++ b/packages/common-utils/src/__tests__/types.test.ts @@ -457,3 +457,98 @@ describe('alternateRowBackground on saved chart configs', () => { expect(parsed).toMatchObject({ alternateRowBackground: true }); }); }); + +describe('line/area display settings on saved chart configs', () => { + // showLegend / tooltipMode / lineInterpolation live on SharedChartSettingsSchema + // so builder, raw SQL, and PromQL saved configs all carry them. They are + // optional, so existing tiles (which never set them) still parse unchanged. + + const builderBase = { + source: 'test-source', + timestampValueExpression: 'Timestamp', + displayType: 'line', + select: [{ aggFn: 'count', valueExpression: '', alias: 'Count' }], + where: '', + }; + const rawSqlBase = { + configType: 'sql' as const, + sqlTemplate: 'SELECT count() AS Count, toStartOfMinute(Timestamp) AS ts', + connection: 'test-connection', + displayType: 'line', + }; + const promqlBase = { + configType: 'promql' as const, + promqlExpression: 'up', + connection: 'test-connection', + displayType: 'line', + }; + + it('retains the three settings on a builder line saved config', () => { + const parsed = SavedChartConfigSchema.parse({ + ...builderBase, + showLegend: false, + tooltipMode: 'single', + lineInterpolation: 'step', + }); + + expect(parsed).toMatchObject({ + showLegend: false, + tooltipMode: 'single', + lineInterpolation: 'step', + }); + }); + + it('retains the three settings on a raw SQL line saved config', () => { + const parsed = SavedChartConfigSchema.parse({ + ...rawSqlBase, + showLegend: true, + tooltipMode: 'all', + lineInterpolation: 'linear', + }); + + expect(parsed).toMatchObject({ + showLegend: true, + tooltipMode: 'all', + lineInterpolation: 'linear', + }); + }); + + it('retains the three settings on a PromQL line saved config', () => { + const parsed = SavedChartConfigSchema.parse({ + ...promqlBase, + tooltipMode: 'hidden', + lineInterpolation: 'monotone', + }); + + expect(parsed).toMatchObject({ + tooltipMode: 'hidden', + lineInterpolation: 'monotone', + }); + }); + + it('parses a config that omits all three (they are optional)', () => { + const parsed = SavedChartConfigSchema.parse(builderBase); + + expect(parsed).not.toHaveProperty('showLegend'); + expect(parsed).not.toHaveProperty('tooltipMode'); + expect(parsed).not.toHaveProperty('lineInterpolation'); + }); + + it('rejects a tooltipMode outside the enum', () => { + expect( + SavedChartConfigSchema.safeParse({ + ...builderBase, + tooltipMode: 'nearest', + }).success, + ).toBe(false); + }); + + it('rejects a lineInterpolation outside the enum', () => { + expect( + SavedChartConfigSchema.safeParse({ + ...builderBase, + lineInterpolation: 'smooth', + }).success, + ).toBe(false); + }); +}); diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 9ef47c1da4..6415212fef 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1237,6 +1237,23 @@ const SharedChartSettingsSchema = z.object({ // types ignore the field. Off by default, so existing tiles are unchanged. // Kept at shared level mirroring `color` / `colorRules` / `backgroundChart`. alternateRowBackground: z.boolean().optional(), + // Line/area time-chart display controls, gated in the UI on + // `displayType === DisplayType.Line` and read by the renderer only (the + // app's DBTimeChart -> HDXMultiSeriesTimeChart path). All optional, so + // existing tiles are unchanged and other display types ignore them. The v2 + // external API builds tile config by explicit per-displayType construction + // (routers/external-api/v2/utils/dashboards.ts), so these are NOT part of + // the external contract until added there in a follow-up. + // + // Whether the chart's series legend is drawn. + showLegend: z.boolean().optional(), + // Hover tooltip behavior. 'auto' (or unset) derives the mode from the chart's + // density; the explicit values override that (see resolveTooltipMode and + // resolveEffectiveTooltipMode in HDXMultiSeriesTimeChart). + tooltipMode: z.enum(['auto', 'single', 'all', 'hidden']).optional(), + // Curve interpolation for the drawn series. Defaults to 'monotone' (smooth) + // at render when unset. + lineInterpolation: z.enum(['linear', 'monotone', 'step']).optional(), }); // How a grouped ratio divides once split into numerator/denominator series: