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: