From 08960d7d7a7e5e245135194aa9ac362bbdba0dac Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:26:28 +0000 Subject: [PATCH] feat(app): configurable time chart display settings Add Show Legend, Hover Tooltip mode, and Line Style controls to the time chart Display Settings drawer, persisted on the tile config. - showLegend / tooltipMode / lineInterpolation are optional fields on SharedChartSettingsSchema, so existing tiles are unchanged and other display types ignore them. - The drawer gates the controls to line/area tiles (DisplayType.Line), mirroring the existing displayType-gated sections. - The renderer reads them through DBTimeChart into MemoChart: the legend prop combines with the config, the tooltip mode folds an explicit setting over the density default (resolveEffectiveTooltipMode), and the Area curve type uses the configured interpolation (default monotone). These stay app-side only. The v2 external API builds tile config by explicit per-displayType construction, so the fields are not part of the external contract yet; a follow-up will add v2 parity, OpenAPI, and docs. Co-Authored-By: Claude Opus 4.8 --- .changeset/timechart-display-settings.md | 18 +++ packages/app/src/HDXMultiSeriesTimeChart.tsx | 59 ++++++- .../__tests__/HDXMultiSeriesTimeChart.test.ts | 50 ++++++ .../components/ChartDisplaySettingsDrawer.tsx | 67 ++++++++ .../DBEditTimeChartForm/EditTimeChartForm.tsx | 18 +++ packages/app/src/components/DBTimeChart.tsx | 8 +- .../ChartDisplaySettingsDrawer.test.tsx | 149 ++++++++++++++++++ .../common-utils/src/__tests__/types.test.ts | 95 +++++++++++ packages/common-utils/src/types.ts | 17 ++ 9 files changed, 474 insertions(+), 7 deletions(-) create mode 100644 .changeset/timechart-display-settings.md 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: