Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/custom-chart-categorical-x-axis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

Custom (Query mode) and dashboard charts keep non-date x-axis labels like run IDs and task names readable with no configuration: width-aware label thinning, middle-truncation with the full value on hover, and auto-rotation only when labels are long.
6 changes: 6 additions & 0 deletions .server-changes/dashboard-line-chart-x-axis-density.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

Dashboard and custom query line charts now choose how many x-axis time labels to show based on the chart's rendered width, so wide charts show more labels and narrow widgets show fewer.
6 changes: 6 additions & 0 deletions .server-changes/task-landing-activity-charts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

Improve the activity charts on the task landing pages (agent, standard, scheduled): bar density now adapts to the selected time range so short ranges no longer collapse to a single bar, x-axis labels are width-aware and non-overlapping, the agent charts share a synced hover line, each chart gets a maximize button, and dragging across a chart zooms the Time/Date filter.
168 changes: 129 additions & 39 deletions apps/webapp/app/components/code/QueryResultsChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import type { ColumnFormatType, OutputColumnMetadata } from "@internal/clickhous
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { BarChart3, LineChart } from "lucide-react";
import { memo, useMemo } from "react";
import { useMeasure } from "react-use";
import { createValueFormatter } from "~/utils/columnFormat";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import type { ChartConfig } from "~/components/primitives/charts/Chart";
import { Chart } from "~/components/primitives/charts/ChartCompound";
import { selectEvenlySpacedIndices } from "~/components/primitives/charts/useXAxisTicks";
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
import { Callout } from "../primitives/Callout";
import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget";
Expand All @@ -18,6 +20,39 @@ const MAX_SVG_ELEMENT_BUDGET = 6_000;
const MIN_DATA_POINTS = 100;
const MAX_DATA_POINTS = 500;

// Width-aware x-axis label density for date-based line charts: reserve room for
// the y-axis + margins, then fit one label per TIME_AXIS_LABEL_SPACING_PX (smaller = denser).
const TIME_AXIS_Y_ALLOWANCE_PX = 56;
const TIME_AXIS_LABEL_SPACING_PX = 40;
const MIN_TIME_AXIS_TICKS = 3;

// Categorical (non-date) x-axis: thin labels to fit, middle-truncate long values
// (run IDs, task names), and auto-rotate when labels are long.
const X_LABEL_PX_PER_CHAR = 6.5;
const X_LABEL_GAP_PX = 16;
const MIN_CATEGORICAL_LABEL_PX = 36;
// Labels longer than this rotate to -45°.
const CATEGORICAL_HORIZONTAL_MAX_CHARS = 10;
// Middle-ellipsis cap for rotated labels (bounds axis height).
const CATEGORICAL_ROTATED_MAX_CHARS = 14;
// Rotated labels pack tighter than horizontal ones.
const CATEGORICAL_ROTATED_LABEL_PX = 32;
const CATEGORICAL_ROTATED_HEIGHT_PX = 80;
const MIN_CATEGORICAL_TICKS = 2;

/**
* Shorten to `maxChars` with a middle ellipsis (e.g. "run_abc…f9c2"), preserving
* the distinguishing tail for IDs that share a prefix.
*/
export function truncateMiddle(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
if (maxChars <= 1) return value.slice(0, Math.max(0, maxChars));
const keep = maxChars - 1; // room for the ellipsis
const head = Math.ceil(keep / 2);
const tail = Math.floor(keep / 2);
return `${value.slice(0, head)}…${value.slice(value.length - tail)}`;
}

interface QueryResultsChartProps {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
Expand Down Expand Up @@ -307,7 +342,7 @@ const NICE_TIME_INTERVALS = [
* Generate evenly-spaced tick values for a time axis using "nice" intervals
* that align to natural time boundaries (midnight, noon, hour marks, etc.)
*/
function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): number[] {
export function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): number[] {
const range = maxTime - minTime;

if (range <= 0) {
Expand Down Expand Up @@ -793,6 +828,22 @@ export const QueryResultsChart = memo(function QueryResultsChart({
timeTicks,
} = useMemo(() => transformDataForChart(rows, config, timeRange), [rows, config, timeRange]);

// Measure the chart width so x-axis label density adapts to it (continuous time
// scale is kept, so data gaps still show).
const [chartMeasureRef, { width: chartWidth }] = useMeasure<HTMLDivElement>();

// Width-aware time-axis ticks: choose how many clean intervals to render based
// on width. Falls back to the default ticks until width is known (first paint).
const widthAwareTimeTicks = useMemo(() => {
if (!isDateBased || !timeDomain || !chartWidth) return timeTicks;
const plotWidth = Math.max(0, chartWidth - TIME_AXIS_Y_ALLOWANCE_PX);
const maxTicks = Math.max(
MIN_TIME_AXIS_TICKS,
Math.floor(plotWidth / TIME_AXIS_LABEL_SPACING_PX)
);
return generateTimeTicks(timeDomain[0], timeDomain[1], maxTicks);
}, [isDateBased, timeDomain, timeTicks, chartWidth]);

// Apply sorting (for date-based, sort by timestamp to ensure correct order)
const data = useMemo(() => {
if (isDateBased) {
Expand Down Expand Up @@ -1022,6 +1073,41 @@ export const QueryResultsChart = memo(function QueryResultsChart({
};
}, [isDateBased, xAxisTickFormatter, xAxisAngle]);

// Categorical x-axis: thin to fit width, middle-truncate long values, rotate when long.
const categoricalXAxisProps = useMemo(() => {
if (isDateBased) return null;

const labels = data.map((d) => String(d[xDataKey] ?? ""));
const maxLabelChars = labels.reduce((max, l) => Math.max(max, l.length), 0);
const angled = maxLabelChars > CATEGORICAL_HORIZONTAL_MAX_CHARS;

const tickFormatter = angled
? (value: unknown) => truncateMiddle(String(value ?? ""), CATEGORICAL_ROTATED_MAX_CHARS)
: (value: unknown) => String(value ?? "");

// Rotated labels pack tighter; horizontal ones need roughly their own width.
const perLabelPx = angled
? CATEGORICAL_ROTATED_LABEL_PX
: Math.max(MIN_CATEGORICAL_LABEL_PX, maxLabelChars * X_LABEL_PX_PER_CHAR + X_LABEL_GAP_PX);
const plotWidth = chartWidth > 0 ? Math.max(0, chartWidth - TIME_AXIS_Y_ALLOWANCE_PX) : 0;
const fitCount =
plotWidth > 0
? Math.max(MIN_CATEGORICAL_TICKS, Math.floor(plotWidth / perLabelPx))
: data.length;
const ticks =
fitCount < data.length
? selectEvenlySpacedIndices(data.length, fitCount).map((i) => data[i][xDataKey] as string)
: undefined;

return {
tickFormatter,
angle: angled ? -45 : 0,
textAnchor: angled ? ("end" as const) : ("middle" as const),
height: angled ? CATEGORICAL_ROTATED_HEIGHT_PX : undefined,
...(ticks ? { ticks, interval: 0 as const } : {}),
};
Comment thread
samejr marked this conversation as resolved.
}, [isDateBased, data, xDataKey, chartWidth]);

// Validation — all hooks must be above this point
const chartIcon = chartType === "bar" ? BarChart3 : LineChart;

Expand Down Expand Up @@ -1070,15 +1156,15 @@ export const QueryResultsChart = memo(function QueryResultsChart({
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
scale: "time" as const,
// Explicitly specify tick positions so labels appear across the entire range
ticks: timeTicks ?? undefined,
ticks: widthAwareTimeTicks ?? undefined,
...baseXAxisProps,
}
: baseXAxisProps;
: (categoricalXAxisProps ?? baseXAxisProps);

// Bar charts always use categorical axis positioning
// This ensures bars are evenly distributed regardless of data point count
// (prevents massive bars when there are only a few data points)
const xAxisPropsForBar = baseXAxisProps;
const xAxisPropsForBar = isDateBased ? baseXAxisProps : (categoricalXAxisProps ?? baseXAxisProps);

const yAxisProps = {
tickFormatter: yAxisFormatter,
Expand All @@ -1089,6 +1175,40 @@ export const QueryResultsChart = memo(function QueryResultsChart({

if (chartType === "bar") {
return (
<div ref={chartMeasureRef} className="h-full w-full">
<Chart.Root
config={chartConfig}
data={data}
dataKey={xDataKey}
series={sortedSeries}
visibleSeries={visibleSeries}
labelFormatter={legendLabelFormatter}
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
legendAggregation={config.aggregation}
legendValueFormatter={tooltipValueFormatter}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
state={isLoading ? "loading" : "loaded"}
beforeLegend={seriesLimitCallout}
>
<Chart.Bar
xAxisProps={xAxisPropsForBar}
yAxisProps={yAxisProps}
stackId={stacked ? "stack" : undefined}
tooltipLabelFormatter={tooltipLabelFormatter}
tooltipValueFormatter={tooltipValueFormatter}
/>
</Chart.Root>
</div>
);
}

// Line or stacked area chart
return (
<div ref={chartMeasureRef} className="h-full w-full">
<Chart.Root
config={chartConfig}
data={data}
Expand All @@ -1107,46 +1227,16 @@ export const QueryResultsChart = memo(function QueryResultsChart({
state={isLoading ? "loading" : "loaded"}
beforeLegend={seriesLimitCallout}
>
<Chart.Bar
xAxisProps={xAxisPropsForBar}
<Chart.Line
xAxisProps={xAxisPropsForLine}
yAxisProps={yAxisProps}
stackId={stacked ? "stack" : undefined}
stacked={stacked && visibleSeries.length > 1}
tooltipLabelFormatter={tooltipLabelFormatter}
tooltipValueFormatter={tooltipValueFormatter}
lineType="linear"
/>
</Chart.Root>
);
}

// Line or stacked area chart
return (
<Chart.Root
config={chartConfig}
data={data}
dataKey={xDataKey}
series={sortedSeries}
visibleSeries={visibleSeries}
labelFormatter={legendLabelFormatter}
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
legendAggregation={config.aggregation}
legendValueFormatter={tooltipValueFormatter}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
state={isLoading ? "loading" : "loaded"}
beforeLegend={seriesLimitCallout}
>
<Chart.Line
xAxisProps={xAxisPropsForLine}
yAxisProps={yAxisProps}
stacked={stacked && visibleSeries.length > 1}
tooltipLabelFormatter={tooltipLabelFormatter}
tooltipValueFormatter={tooltipValueFormatter}
lineType="linear"
/>
</Chart.Root>
</div>
);
});

Expand Down
Loading