From 817d2e190404b7184b26c2d34c8243200c119d78 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 29 Oct 2026 11:01:43 +0100 Subject: [PATCH 1/3] feat(cli): label explore charts with shared axes and legends --- .../src/fragments/commands/explore.md | 17 +- .../src/lib/formatters/chart-presentation.ts | 263 ++++++++++++++++++ .../formatters/explore-timeseries-chart.ts | 37 ++- .../cli/src/lib/formatters/sixel-dashboard.ts | 224 ++------------- .../lib/formatters/explore-timeseries.test.ts | 105 +++++-- .../lib/formatters/sixel-dashboard.test.ts | 2 +- 6 files changed, 404 insertions(+), 244 deletions(-) create mode 100644 packages/cli/src/lib/formatters/chart-presentation.ts diff --git a/apps/cli-docs/src/fragments/commands/explore.md b/apps/cli-docs/src/fragments/commands/explore.md index f0a30f788..f3d4e4bc3 100644 --- a/apps/cli-docs/src/fragments/commands/explore.md +++ b/apps/cli-docs/src/fragments/commands/explore.md @@ -27,15 +27,18 @@ recent buckets may still be waiting for ingestion or for the interval to end. Temporal output has no pagination cursor. In terminals with Kitty or Sixel graphics support, each numeric aggregate gets -its own chart and scale. Null and incomplete buckets leave gaps; a table below -the chart retains their timestamps, original values, and completeness reasons. -Complete buckets have at least a baseline tick, including zero values. Sampling, -scan coverage, and ingestion warnings remain visible. All times are UTC. +its own chart with a value axis, UTC time labels, and a colored aggregate legend, +using the same presentation as dashboard charts. Null and incomplete buckets leave +gaps; a table below the chart retains their timestamps, original values, and +completeness reasons. Complete buckets have at least a baseline tick, including +zero values. Sampling, scan coverage, and ingestion warnings remain visible. +All times are UTC. Other terminals use tables, as do series with strings, negative values, irregular -bucket spacing, or too many buckets to draw individually. The CLI never averages -buckets to fit an Explore chart. Use `SENTRY_NO_GRAPHICS=1` or the graphics default -setting to keep table output. Graphics do not change `--json` or `--fields`. +bucket spacing, too many buckets to draw individually, or too little room for +readable axes. The CLI never averages buckets to fit an Explore chart. Use +`SENTRY_NO_GRAPHICS=1` or the graphics default setting to keep table output. +Graphics do not change `--json` or `--fields`. ## Metrics diff --git a/packages/cli/src/lib/formatters/chart-presentation.ts b/packages/cli/src/lib/formatters/chart-presentation.ts new file mode 100644 index 000000000..663446305 --- /dev/null +++ b/packages/cli/src/lib/formatters/chart-presentation.ts @@ -0,0 +1,263 @@ +/** + * Shared chart presentation for terminal graphics. + * + * Composes the existing bar rasterizer with axes, caller-formatted labels, and + * a series legend. Data conversion, units, and time zones belong to the caller. + */ + +import type { DecodedImage } from "../sixel-image.js"; +import { + type ChartModel, + hexToRgb, + rasterizeChart, + seriesColor, +} from "./chart-core.js"; +import { + blitPixelImage, + createPixelCanvas, + drawPixelRect, + drawPixelText, + type Rgb, +} from "./pixel-canvas.js"; + +/** Muted axes that remain visible on light and dark terminal backgrounds. */ +const FRAME_COLOR: Rgb = [128, 128, 128]; + +/** The same text color used by the dashboard compositor. */ +const TEXT_COLOR: Rgb = [224, 224, 224]; + +/** Aggregate function and its simple arguments in a raw query expression. */ +const AGGREGATE_LABEL_RE = /^([a-z][a-z0-9_]*)\(([^()]*)\)/i; + +/** Presentation and fitting options for a chart with axes and a legend. */ +export type ChartPresentationOptions = { + /** Total canvas width in pixels, including the value-axis gutter. */ + width: number; + /** Total canvas height in pixels, including time labels and the legend. */ + height: number; + /** Width of one terminal text cell in pixels. */ + cellWidth: number; + /** Height of one terminal text cell in pixels. */ + cellHeight: number; + /** Number of text cells reserved for the value axis; zero hides that axis. */ + gutterColumns: number; + /** Value-axis maximum already formatted with the caller's units/precision. */ + maximumLabel: string; + /** First x-axis label; omitted values leave that label empty. */ + firstLabel?: string; + /** Last x-axis label; omitted values leave that label empty. */ + lastLabel?: string; + /** Minimum single-series bar height in pixels; nulls stay empty. Default: 0. */ + minBarHeight?: number; + /** Allow the core's numeric bucket averaging to fit the plot. Default: true. */ + allowDownsampling?: boolean; +}; + +/** + * Render a chart, its axes, and a colored legend into one transparent canvas. + * Returns undefined when the plot cannot fit or disallowed averaging is needed. + * Labels and legend use the dashboard's existing height-based visibility rules. + */ +export function renderChartWithLabels( + model: ChartModel, + options: ChartPresentationOptions, +): DecodedImage | undefined { + const { cellWidth, cellHeight, gutterColumns } = options; + const width = Math.floor(options.width); + const height = Math.floor(options.height); + const contentRows = Math.max(1, Math.floor(height / cellHeight)); + const hasLegend = contentRows >= 4; + const hasAxisLabels = contentRows >= 5; + const gutterWidth = gutterColumns * cellWidth; + const footerHeight = + (hasLegend ? 1 : 0) * cellHeight + (hasAxisLabels ? 1 : 0) * cellHeight; + // Axes occupy their own pixels so a zero/tiny-value tick never covers a gap. + const plotX = gutterWidth + (gutterColumns > 0 ? 1 : 0); + const plotWidth = Math.floor(width - plotX); + const plotHeight = Math.floor(height - footerHeight - 1); + if ( + plotWidth < 16 || + plotHeight < 8 || + (options.allowDownsampling === false && + model.kind === "timeseries" && + model.buckets > Math.floor(plotWidth / 2)) + ) { + return; + } + const chart = rasterizeChart(model, { + width: plotWidth, + height: plotHeight, + backgroundTransparent: true, + minBarHeight: options.minBarHeight, + }); + if (!chart) { + return; + } + + const image = createPixelCanvas({ width, height }); + blitPixelImage(image, chart, plotX, 0); + drawPixelRect(image, { + x: gutterWidth, + y: plotHeight, + width: width - gutterWidth, + height: 1, + color: FRAME_COLOR, + }); + if (gutterColumns > 0) { + drawPixelRect(image, { + x: gutterWidth, + y: 0, + width: 1, + height: plotHeight + 1, + color: FRAME_COLOR, + }); + drawPixelText(image, options.maximumLabel, { + x: 0, + y: 0, + cellWidth, + cellHeight, + maxColumns: gutterColumns, + color: FRAME_COLOR, + }); + drawPixelText(image, "0", { + x: 0, + y: plotHeight - cellHeight, + cellWidth, + cellHeight, + maxColumns: gutterColumns, + color: FRAME_COLOR, + }); + } + + let footerY = plotHeight + 1; + if (hasAxisLabels) { + drawChartLabels(image, { + first: options.firstLabel ?? "", + last: options.lastLabel ?? "", + x: plotX, + y: footerY, + width: plotWidth, + cellWidth, + cellHeight, + }); + footerY += cellHeight; + } + if (hasLegend) { + drawLegend(image, { + series: model.series, + x: 0, + y: footerY, + width, + cellWidth, + cellHeight, + }); + } + return image; +} + +/** Draw concise first/last labels without interpreting the caller's data. */ +function drawChartLabels( + image: DecodedImage, + options: { + first: string; + last: string; + x: number; + y: number; + width: number; + cellWidth: number; + cellHeight: number; + }, +): void { + const columns = Math.max(1, Math.floor(options.width / options.cellWidth)); + drawPixelText(image, options.first, { + x: options.x, + y: options.y, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + maxColumns: Math.max(1, Math.floor(columns / 2)), + color: FRAME_COLOR, + }); + const lastColumns = Math.min(Math.floor(columns / 2), options.last.length); + drawPixelText(image, options.last, { + x: options.x + Math.max(0, columns - lastColumns) * options.cellWidth, + y: options.y, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + maxColumns: Math.max(1, lastColumns), + color: FRAME_COLOR, + }); +} + +/** Draw colored series keys and labels, clipping safely to the widget width. */ +function drawLegend( + image: ReturnType, + options: { + series: { label: string }[]; + x: number; + y: number; + width: number; + cellWidth: number; + cellHeight: number; + }, +): void { + let column = 0; + const maxColumns = Math.max(1, Math.floor(options.width / options.cellWidth)); + for (let index = 0; index < options.series.length; index += 1) { + const series = options.series[index]; + if (!series || column >= maxColumns) { + break; + } + const availableColumns = maxColumns - column - 2; + const label = truncateLegendLabel( + formatLegendLabel(series.label), + Math.min(18, availableColumns), + ); + const requiredColumns = Math.min(maxColumns, label.length + 2); + if (column + requiredColumns > maxColumns) { + break; + } + drawPixelRect(image, { + x: options.x + column * options.cellWidth, + y: options.y + Math.max(1, Math.floor(options.cellHeight / 3)), + width: Math.max(2, Math.floor(options.cellWidth / 2)), + height: Math.max(2, Math.floor(options.cellHeight / 3)), + color: hexToRgb(seriesColor(series.label, index)), + }); + drawPixelText(image, label, { + x: options.x + (column + 1) * options.cellWidth, + y: options.y, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + maxColumns: Math.min(label.length, maxColumns - column - 1), + color: TEXT_COLOR, + }); + column += requiredColumns; + } +} + +/** Turn raw aggregate expressions into concise, readable legend labels. */ +export function formatLegendLabel(label: string): string { + const aggregate = AGGREGATE_LABEL_RE.exec(label); + if (aggregate) { + const operation = aggregate[1] ?? label; + const arguments_ = + aggregate[2]?.split(",").map((argument) => argument.trim()) ?? []; + const metric = arguments_[0] === "value" ? arguments_[1] : arguments_[0]; + return metric ? `${operation} ${metric}` : operation; + } + if (label.startsWith("equation|")) { + return "equation"; + } + return label; +} + +/** Clip a legend label without leaving a partial aggregate expression. */ +function truncateLegendLabel(label: string, maxColumns: number): string { + if (label.length <= maxColumns) { + return label; + } + if (maxColumns <= 3) { + return label.slice(0, Math.max(0, maxColumns)); + } + return `${label.slice(0, maxColumns - 3)}...`; +} diff --git a/packages/cli/src/lib/formatters/explore-timeseries-chart.ts b/packages/cli/src/lib/formatters/explore-timeseries-chart.ts index 7391e349e..a6cc42241 100644 --- a/packages/cli/src/lib/formatters/explore-timeseries-chart.ts +++ b/packages/cli/src/lib/formatters/explore-timeseries-chart.ts @@ -13,7 +13,7 @@ import { tmuxKittyPlacement, } from "../sixel.js"; import { encodeImageToSixel } from "../sixel-image.js"; -import { rasterizeChart } from "./chart-core.js"; +import { renderChartWithLabels } from "./chart-presentation.js"; import { escapeMarkdownInline, renderMarkdown } from "./markdown.js"; import { formatCellValue } from "./meta-table.js"; import { appendUnitSuffix } from "./numbers.js"; @@ -51,9 +51,6 @@ export function formatExploreTimeSeriesChart( !Number.isFinite(height) || !Number.isFinite(meta.interval) || meta.interval <= 0 || - // The shared engine averages dense series. That is not valid for arbitrary - // Explore aggregates (for example percentiles and unique counts). - values.length > Math.floor(width / 2) || values.some( (bucket, index) => !Number.isFinite(bucket.timestamp) || @@ -74,7 +71,21 @@ export function formatExploreTimeSeriesChart( return; } const maximum = Math.max(...complete); - const image = rasterizeChart( + const maximumLabel = formatMaximum(maximum, series); + const firstLabel = `${formatUtcTimestamp(first.timestamp)} UTC`; + const lastLabel = `${formatUtcTimestamp(last.timestamp)} UTC`; + const gutterColumns = Math.max(5, maximumLabel.length + 1); + const plotWidth = width - gutterColumns * cell.cellWidth - 1; + // Keep the full value scale and UTC labels readable; a clipped date or unit + // is less useful than the table on narrow or unusually tall-cell terminals. + if ( + plotWidth < + 2 * Math.max(firstLabel.length, lastLabel.length) * cell.cellWidth || + height < 5 * cell.cellHeight + ) { + return; + } + const image = renderChartWithLabels( { kind: "timeseries", series: [{ label: series.yAxis, values: plotted }], @@ -82,7 +93,19 @@ export function formatExploreTimeSeriesChart( maxVal: maximum || 1, stacked: false, }, - { width, height, minBarHeight: 1 }, + { + width, + height, + ...cell, + gutterColumns, + maximumLabel, + firstLabel, + lastLabel, + minBarHeight: 1, + // Percentiles and unique counts cannot be averaged when axes reduce the + // plot width. Let the shared renderer decline graphics in that case. + allowDownsampling: false, + }, ); if (!image) { return; @@ -95,7 +118,7 @@ export function formatExploreTimeSeriesChart( return; } const scale = renderMarkdown( - `Scale: 0 to ${escapeMarkdownInline(formatMaximum(maximum, series))}`, + `Scale: 0 to ${escapeMarkdownInline(maximumLabel)}`, ); const range = renderMarkdown( `Bucket starts (UTC): ${formatUtcTimestamp(first.timestamp)} to ${formatUtcTimestamp(last.timestamp)}`, diff --git a/packages/cli/src/lib/formatters/sixel-dashboard.ts b/packages/cli/src/lib/formatters/sixel-dashboard.ts index b4256e36a..dd65baa33 100644 --- a/packages/cli/src/lib/formatters/sixel-dashboard.ts +++ b/packages/cli/src/lib/formatters/sixel-dashboard.ts @@ -14,11 +14,9 @@ import { buildCategoricalChartModel, buildChartModel, buildHeatmapModel, - hexToRgb, - rasterizeChart, rasterizeHeatmap, - seriesColor, } from "./chart-core.js"; +import { renderChartWithLabels } from "./chart-presentation.js"; import { blitPixelImage, createPixelCanvas, @@ -48,9 +46,6 @@ const TERMINAL_ESCAPE_RE = new RegExp( "g", ); -/** Aggregate function and its simple arguments in a raw query expression. */ -const AGGREGATE_LABEL_RE = /^([a-z][a-z0-9_]*)\(([^()]*)\)/i; - /** A widget layout used by the dashboard grid. */ export type SixelWidgetLayout = { /** Left grid column. */ @@ -334,218 +329,31 @@ function drawChartContent( if (!model) { return; } - const contentRows = Math.max( - 1, - Math.floor(options.height / options.cellHeight), - ); - const hasLegend = contentRows >= 4; - const hasAxisLabels = contentRows >= 5; - const gutterColumns = options.width >= options.cellWidth * 12 ? 5 : 0; - const gutterWidth = gutterColumns * options.cellWidth; - const footerHeight = - (hasLegend ? 1 : 0) * options.cellHeight + - (hasAxisLabels ? 1 : 0) * options.cellHeight; - const chartX = options.x + gutterWidth; - const chartWidth = options.width - gutterWidth; - const chartHeight = options.height - footerHeight; - // rasterizeChart enforces a 16x8 minimum, which must never escape this - // widget's allocated rectangle on exceptionally narrow terminals. - if (chartWidth < 16 || chartHeight < 8) { - return; - } - const chart = rasterizeChart(model, { - width: chartWidth, - height: chartHeight, - backgroundTransparent: true, - }); - if (!chart) { - return; - } - - blitPixelImage(image, chart, chartX, options.y); - drawPixelRect(image, { - x: chartX, - y: options.y + chartHeight - 1, - width: chartWidth, - height: 1, - color: FRAME_COLOR, - }); - if (gutterWidth > 0) { - drawPixelRect(image, { - x: chartX, - y: options.y, - width: 1, - height: chartHeight, - color: FRAME_COLOR, - }); - drawPixelText( - image, - formatChartValue(model.maxVal, options.data.series[0]?.unit), - { - x: options.x, - y: options.y, - cellWidth: options.cellWidth, - cellHeight: options.cellHeight, - maxColumns: gutterColumns, - color: FRAME_COLOR, - }, - ); - drawPixelText(image, "0", { - x: options.x, - y: options.y + chartHeight - options.cellHeight, - cellWidth: options.cellWidth, - cellHeight: options.cellHeight, - maxColumns: gutterColumns, - color: FRAME_COLOR, - }); - } - - let footerY = options.y + chartHeight; - if (hasAxisLabels) { - drawChartLabels(image, { - modelKind: model.kind, - series: model.series, - data: options.data, - x: chartX, - y: footerY, - width: chartWidth, - cellWidth: options.cellWidth, - cellHeight: options.cellHeight, - }); - footerY += options.cellHeight; - } - if (hasLegend) { - drawLegend(image, { - series: model.series, - x: options.x, - y: footerY, - width: options.width, - cellWidth: options.cellWidth, - cellHeight: options.cellHeight, - }); - } -} - -/** Draw a concise first/last x-axis label for time and categorical charts. */ -function drawChartLabels( - image: ReturnType, - options: { - modelKind: "timeseries" | "categorical"; - series: { label: string }[]; - data: Extract; - x: number; - y: number; - width: number; - cellWidth: number; - cellHeight: number; - }, -): void { - const columns = Math.max(1, Math.floor(options.width / options.cellWidth)); const firstTimestamp = options.data.series[0]?.values[0]?.timestamp; const lastTimestamp = options.data.series[0]?.values.at(-1)?.timestamp; const spanDays = typeof firstTimestamp === "number" && typeof lastTimestamp === "number" ? (lastTimestamp - firstTimestamp) / (24 * 60 * 60) : 0; - const first = - options.modelKind === "categorical" - ? options.series[0]?.label - : formatTimestamp(firstTimestamp, spanDays); - const last = - options.modelKind === "categorical" - ? options.series.at(-1)?.label - : formatTimestamp(lastTimestamp, spanDays); - drawPixelText(image, first ?? "", { - x: options.x, - y: options.y, - cellWidth: options.cellWidth, - cellHeight: options.cellHeight, - maxColumns: Math.max(1, Math.floor(columns / 2)), - color: FRAME_COLOR, - }); - const lastColumns = Math.min(Math.floor(columns / 2), (last ?? "").length); - drawPixelText(image, last ?? "", { - x: options.x + Math.max(0, columns - lastColumns) * options.cellWidth, - y: options.y, + const chart = renderChartWithLabels(model, { + width: options.width, + height: options.height, cellWidth: options.cellWidth, cellHeight: options.cellHeight, - maxColumns: Math.max(1, lastColumns), - color: FRAME_COLOR, + gutterColumns: options.width >= options.cellWidth * 12 ? 5 : 0, + maximumLabel: formatChartValue(model.maxVal, options.data.series[0]?.unit), + firstLabel: + model.kind === "categorical" + ? model.series[0]?.label + : formatTimestamp(firstTimestamp, spanDays), + lastLabel: + model.kind === "categorical" + ? model.series.at(-1)?.label + : formatTimestamp(lastTimestamp, spanDays), }); -} - -/** Draw colored series keys and labels, clipping safely to the widget width. */ -function drawLegend( - image: ReturnType, - options: { - series: { label: string }[]; - x: number; - y: number; - width: number; - cellWidth: number; - cellHeight: number; - }, -): void { - let column = 0; - const maxColumns = Math.max(1, Math.floor(options.width / options.cellWidth)); - for (let index = 0; index < options.series.length; index += 1) { - const series = options.series[index]; - if (!series || column >= maxColumns) { - break; - } - const availableColumns = maxColumns - column - 2; - const label = truncateLegendLabel( - formatLegendLabel(series.label), - Math.min(18, availableColumns), - ); - const requiredColumns = Math.min(maxColumns, label.length + 2); - if (column + requiredColumns > maxColumns) { - break; - } - drawPixelRect(image, { - x: options.x + column * options.cellWidth, - y: options.y + Math.max(1, Math.floor(options.cellHeight / 3)), - width: Math.max(2, Math.floor(options.cellWidth / 2)), - height: Math.max(2, Math.floor(options.cellHeight / 3)), - color: hexToRgb(seriesColor(series.label, index)), - }); - drawPixelText(image, label, { - x: options.x + (column + 1) * options.cellWidth, - y: options.y, - cellWidth: options.cellWidth, - cellHeight: options.cellHeight, - maxColumns: Math.min(label.length, maxColumns - column - 1), - color: TEXT_COLOR, - }); - column += requiredColumns; - } -} - -/** Turn raw aggregate expressions into concise, readable legend labels. */ -export function formatLegendLabel(label: string): string { - const aggregate = AGGREGATE_LABEL_RE.exec(label); - if (aggregate) { - const operation = aggregate[1] ?? label; - const arguments_ = - aggregate[2]?.split(",").map((argument) => argument.trim()) ?? []; - const metric = arguments_[0] === "value" ? arguments_[1] : arguments_[0]; - return metric ? `${operation} ${metric}` : operation; - } - if (label.startsWith("equation|")) { - return "equation"; - } - return label; -} - -/** Clip a legend label without leaving a partial aggregate expression. */ -function truncateLegendLabel(label: string, maxColumns: number): string { - if (label.length <= maxColumns) { - return label; - } - if (maxColumns <= 3) { - return label.slice(0, Math.max(0, maxColumns)); + if (chart) { + blitPixelImage(image, chart, options.x, options.y); } - return `${label.slice(0, maxColumns - 3)}...`; } /** Format a number compactly enough for the chart-axis gutter. */ diff --git a/packages/cli/test/lib/formatters/explore-timeseries.test.ts b/packages/cli/test/lib/formatters/explore-timeseries.test.ts index 7fc74fd3b..4657a2e1a 100644 --- a/packages/cli/test/lib/formatters/explore-timeseries.test.ts +++ b/packages/cli/test/lib/formatters/explore-timeseries.test.ts @@ -8,6 +8,11 @@ import { renderCommandOutput, resolveRenderer, } from "../../../src/lib/formatters/output.js"; +import { + createPixelCanvas, + drawPixelText, + type Rgb, +} from "../../../src/lib/formatters/pixel-canvas.js"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- terminal capability boundary for vi.spyOn import * as terminal from "../../../src/lib/sixel.js"; import type { DecodedImage } from "../../../src/lib/sixel-image.js"; @@ -234,6 +239,34 @@ function alphaAt( return image.data[(y * image.width + x) * 4 + 3]; } +/** Read a bitmap text region independently of the chart's layout calculation. */ +function textPixels( + image: DecodedImage, + x: number, + y: number, + columns: number, +): Buffer { + const rows = Array.from({ length: 8 }, (_, row) => { + const start = ((y + row) * image.width + x) * 4; + return image.data.slice(start, start + columns * 8 * 4); + }); + return Buffer.concat(rows); +} + +/** Reference glyphs for the literal labels a user should see in the image. */ +function labelPixels(text: string, color: Rgb = [128, 128, 128]): Buffer { + const image = createPixelCanvas({ width: text.length * 8, height: 8 }); + drawPixelText(image, text, { + x: 0, + y: 0, + cellWidth: 8, + cellHeight: 8, + maxColumns: text.length, + color, + }); + return Buffer.from(image.data); +} + describe("Explore time-series graphics", () => { let savedColumns: number | undefined; @@ -246,7 +279,7 @@ describe("Explore time-series graphics", () => { cellWidth: 8, cellHeight: 8, }); - vi.spyOn(terminal, "terminalPixelWidth").mockReturnValue(160); + vi.spyOn(terminal, "terminalPixelWidth").mockReturnValue(640); }); afterEach(() => { @@ -289,6 +322,25 @@ describe("Explore time-series graphics", () => { expect(output).toContain("2026-01-01 00:00:00 to 2026-01-01 00:00:15"); expect(output).not.toContain("TIME (UTC)"); expect(data).toEqual(original); + if (format === "kitty") { + const [countImage, durationImage] = kittyImages(output); + expect(textPixels(countImage!, 0, 0, 5)).toEqual(labelPixels("1,234")); + expect(textPixels(durationImage!, 0, 0, 4)).toEqual( + labelPixels("25ms"), + ); + expect(textPixels(durationImage!, 41, 48, 23)).toEqual( + labelPixels("2026-01-01 00:00:00 UTC"), + ); + expect(textPixels(durationImage!, 449, 48, 23)).toEqual( + labelPixels("2026-01-01 00:00:15 UTC"), + ); + expect(textPixels(durationImage!, 8, 56, 17)).toEqual( + labelPixels("p95 span.duration", [224, 224, 224]), + ); + expect( + Array.from(durationImage!.data.slice(58 * 640 * 4, 58 * 640 * 4 + 4)), + ).toEqual([117, 83, 255, 255]); + } }, ); @@ -319,20 +371,20 @@ describe("Explore time-series graphics", () => { expect(images).toHaveLength(1); const image = images[0]!; expect(image.data).toHaveLength(image.width * image.height * 4); - // Eight slots span the image: absent values keep their slots, and a later - // complete bucket keeps its time position instead of closing the gaps. - expect(alphaAt(image, 10, 0)).toBe(255); - expect(alphaAt(image, 30, 63)).toBe(0); - expect(alphaAt(image, 50, 63)).toBe(255); - expect(alphaAt(image, 50, 62)).toBe(0); - expect(alphaAt(image, 70, 63)).toBe(0); - expect(alphaAt(image, 90, 63)).toBe(0); - expect(alphaAt(image, 110, 63)).toBe(0); - expect(alphaAt(image, 130, 31)).toBe(0); - expect(alphaAt(image, 130, 32)).toBe(255); + // Eight slots follow the value-axis gutter. Gaps keep their time positions + // and the axis below the plot must not turn them into visible data ticks. + expect(alphaAt(image, 90, 0)).toBe(255); + expect(alphaAt(image, 160, 46)).toBe(0); + expect(alphaAt(image, 230, 46)).toBe(255); + expect(alphaAt(image, 230, 45)).toBe(0); + expect(alphaAt(image, 310, 46)).toBe(0); + expect(alphaAt(image, 380, 46)).toBe(0); + expect(alphaAt(image, 460, 46)).toBe(0); + expect(alphaAt(image, 530, 22)).toBe(0); + expect(alphaAt(image, 530, 23)).toBe(255); // A small positive value must survive a large peak, just as zero does. - expect(alphaAt(image, 150, 63)).toBe(255); - expect(alphaAt(image, 150, 62)).toBe(0); + expect(alphaAt(image, 610, 46)).toBe(255); + expect(alphaAt(image, 610, 45)).toBe(0); expect(output).toContain("Scale: 0 to 10,000"); expect(output).toContain("Gaps are missing or incomplete buckets"); expect(output).toMatch(/2026-01-01 00:05:00\s+│\s+—/); @@ -368,9 +420,10 @@ describe("Explore time-series graphics", () => { expect(output).toContain("Scale: 0 to 0.002ms"); expect(image).toBeDefined(); - expect(alphaAt(image!, 10, 31)).toBe(0); - expect(alphaAt(image!, 10, 32)).toBe(255); - expect(alphaAt(image!, 90, 0)).toBe(255); + expect(textPixels(image!, 0, 0, 7)).toEqual(labelPixels("0.002ms")); + expect(alphaAt(image!, 100, 22)).toBe(0); + expect(alphaAt(image!, 100, 23)).toBe(255); + expect(alphaAt(image!, 400, 0)).toBe(255); }); test.each(["negative", "string", "irregular", "missing"] as const)( @@ -404,13 +457,13 @@ describe("Explore time-series graphics", () => { }, ); - test("uses the full table instead of averaging dense percentile buckets", () => { + test("uses the full table when axes leave too little plot width for percentile buckets", () => { const data = makeData(); const series = data.timeSeries[0]!; series.yAxis = "p95(span.duration)"; - series.values = Array.from({ length: 81 }, (_, index) => ({ + series.values = Array.from({ length: 300 }, (_, index) => ({ timestamp: Date.UTC(2026, 0, 1) + index * series.meta.interval, - value: index === 80 ? 999 : index, + value: index === 299 ? 999 : index, incomplete: false, })); @@ -418,7 +471,17 @@ describe("Explore time-series graphics", () => { expect(output).not.toContain("\x1b_G"); expect(output).toContain("TIME (UTC)"); - expect(output).toMatch(/2026-01-01 06:40:00\s+│\s+999/); + expect(output).toMatch(/2026-01-02 00:55:00\s+│\s+999/); + }); + + test("uses the table when UTC labels cannot fit without truncation", () => { + vi.mocked(terminal.terminalPixelWidth).mockReturnValue(400); + + const output = formatExploreTimeSeriesHuman(makeData()); + + expect(output).toContain("TIME (UTC)"); + expect(output).toContain("1,234"); + expect(output).not.toContain("\x1b_G"); }); test("uses tables without graphics support or when plain output is requested", () => { diff --git a/packages/cli/test/lib/formatters/sixel-dashboard.test.ts b/packages/cli/test/lib/formatters/sixel-dashboard.test.ts index 9976ada9c..7d7a9d63e 100644 --- a/packages/cli/test/lib/formatters/sixel-dashboard.test.ts +++ b/packages/cli/test/lib/formatters/sixel-dashboard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; +import { formatLegendLabel } from "../../../src/lib/formatters/chart-presentation.js"; import { - formatLegendLabel, formatTimestamp, renderDashboardAsSixel, } from "../../../src/lib/formatters/sixel-dashboard.js"; From a6e62a96c9db3018162121cf3bbb83cd5dce7e4e Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 29 Oct 2026 21:19:17 +0100 Subject: [PATCH 2/3] refactor(cli): compact explore chart captions --- .../src/fragments/commands/explore.md | 6 +- .../src/lib/formatters/chart-presentation.ts | 37 +++++--- .../formatters/explore-timeseries-chart.ts | 16 +--- .../src/lib/formatters/explore-timeseries.ts | 57 +++++++---- .../lib/formatters/explore-timeseries.test.ts | 95 +++++++++++++++---- .../lib/formatters/sixel-dashboard.test.ts | 19 +++- 6 files changed, 165 insertions(+), 65 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/explore.md b/apps/cli-docs/src/fragments/commands/explore.md index f3d4e4bc3..bd65903c2 100644 --- a/apps/cli-docs/src/fragments/commands/explore.md +++ b/apps/cli-docs/src/fragments/commands/explore.md @@ -31,8 +31,10 @@ its own chart with a value axis, UTC time labels, and a colored aggregate legend using the same presentation as dashboard charts. Null and incomplete buckets leave gaps; a table below the chart retains their timestamps, original values, and completeness reasons. Complete buckets have at least a baseline tick, including -zero values. Sampling, scan coverage, and ingestion warnings remain visible. -All times are UTC. +zero values. The interval and sampling or scan warnings appear in a compact +caption below each chart. The full aggregate expression is included there when +the legend abbreviates it. Ingestion details follow the results. All times are UTC; +the query range in the header is distinct from the bucket-start labels on the axes. Other terminals use tables, as do series with strings, negative values, irregular bucket spacing, too many buckets to draw individually, or too little room for diff --git a/packages/cli/src/lib/formatters/chart-presentation.ts b/packages/cli/src/lib/formatters/chart-presentation.ts index 663446305..6dfd43f5d 100644 --- a/packages/cli/src/lib/formatters/chart-presentation.ts +++ b/packages/cli/src/lib/formatters/chart-presentation.ts @@ -208,10 +208,7 @@ function drawLegend( break; } const availableColumns = maxColumns - column - 2; - const label = truncateLegendLabel( - formatLegendLabel(series.label), - Math.min(18, availableColumns), - ); + const { text: label } = formatLegendLabel(series.label, availableColumns); const requiredColumns = Math.min(maxColumns, label.length + 2); if (column + requiredColumns > maxColumns) { break; @@ -235,20 +232,38 @@ function drawLegend( } } -/** Turn raw aggregate expressions into concise, readable legend labels. */ -export function formatLegendLabel(label: string): string { +/** A fitted legend label and whether a caption must retain its full expression. */ +export type ChartLegendLabel = { + /** Text drawn next to the series color key. */ + text: string; + /** True when arguments, expression terms, or clipped characters are omitted. */ + abbreviated: boolean; +}; + +/** Compact and fit a legend label, tracking information lost beyond punctuation. */ +export function formatLegendLabel( + label: string, + availableColumns = 18, +): ChartLegendLabel { + let text = label; + let abbreviated = false; const aggregate = AGGREGATE_LABEL_RE.exec(label); if (aggregate) { const operation = aggregate[1] ?? label; const arguments_ = aggregate[2]?.split(",").map((argument) => argument.trim()) ?? []; const metric = arguments_[0] === "value" ? arguments_[1] : arguments_[0]; - return metric ? `${operation} ${metric}` : operation; - } - if (label.startsWith("equation|")) { - return "equation"; + text = metric ? `${operation} ${metric}` : operation; + abbreviated = + aggregate[0] !== label || + arguments_.length > 1 || + arguments_[0] === "value"; + } else if (label.startsWith("equation|")) { + text = "equation"; + abbreviated = true; } - return label; + const fitted = truncateLegendLabel(text, Math.min(18, availableColumns)); + return { text: fitted, abbreviated: abbreviated || fitted !== text }; } /** Clip a legend label without leaving a partial aggregate expression. */ diff --git a/packages/cli/src/lib/formatters/explore-timeseries-chart.ts b/packages/cli/src/lib/formatters/explore-timeseries-chart.ts index a6cc42241..28fbf8016 100644 --- a/packages/cli/src/lib/formatters/explore-timeseries-chart.ts +++ b/packages/cli/src/lib/formatters/explore-timeseries-chart.ts @@ -14,7 +14,6 @@ import { } from "../sixel.js"; import { encodeImageToSixel } from "../sixel-image.js"; import { renderChartWithLabels } from "./chart-presentation.js"; -import { escapeMarkdownInline, renderMarkdown } from "./markdown.js"; import { formatCellValue } from "./meta-table.js"; import { appendUnitSuffix } from "./numbers.js"; import { isPlainOutput } from "./plain-detect.js"; @@ -110,20 +109,11 @@ export function formatExploreTimeSeriesChart( if (!image) { return; } - const output = - format === "kitty" + return ( + (format === "kitty" ? encodeImageToKitty(image, width, true, tmuxKittyPlacement(columns)) - : encodeImageToSixel(image, width, true); - if (!output) { - return; - } - const scale = renderMarkdown( - `Scale: 0 to ${escapeMarkdownInline(maximumLabel)}`, - ); - const range = renderMarkdown( - `Bucket starts (UTC): ${formatUtcTimestamp(first.timestamp)} to ${formatUtcTimestamp(last.timestamp)}`, + : encodeImageToSixel(image, width, true)) || undefined ); - return `${scale}\n\n${output}\n${range}`; } function formatMaximum(value: number, series: TimeSeries): string { diff --git a/packages/cli/src/lib/formatters/explore-timeseries.ts b/packages/cli/src/lib/formatters/explore-timeseries.ts index 14b7c7c40..18770fb43 100644 --- a/packages/cli/src/lib/formatters/explore-timeseries.ts +++ b/packages/cli/src/lib/formatters/explore-timeseries.ts @@ -4,6 +4,7 @@ */ import type { EventsTimeSeriesResponse } from "../api/explore.js"; +import { formatLegendLabel } from "./chart-presentation.js"; import { formatExploreTimeSeriesChart } from "./explore-timeseries-chart.js"; import { filterFields } from "./json.js"; import { escapeMarkdownInline, renderMarkdown } from "./markdown.js"; @@ -47,11 +48,26 @@ function formatBucketStatus(bucket: Bucket): string { /** Render one measure, retaining metadata and exact values for graph gaps. */ function formatSeries(series: TimeSeries): string { - const details = [ - `**${escapeMarkdownInline(series.yAxis)} over time**`, - `Interval: ${formatDurationCompactMs(series.meta.interval)}`, - ]; - if (series.meta.valueUnit && series.meta.valueUnit !== "none") { + const chart = + series.values.length > 0 ? formatExploreTimeSeriesChart(series) : undefined; + const details: string[] = []; + // Every admitted Explore chart has room for its single fitted legend label. + // Keep the original expression when its fitted label omits more than syntax. + if (!chart || formatLegendLabel(series.yAxis).abbreviated) { + details.push( + `**${escapeMarkdownInline(series.yAxis)}${chart ? "" : " over time"}**`, + ); + } + details.push(`Interval: ${formatDurationCompactMs(series.meta.interval)}`); + // The typed axis formatter appends units only for duration and size values. + const unitOnAxis = + chart && + (series.meta.valueType === "duration" || series.meta.valueType === "size"); + if ( + !unitOnAxis && + series.meta.valueUnit && + series.meta.valueUnit !== "none" + ) { details.push(`Unit: ${escapeMarkdownInline(series.meta.valueUnit)}`); } @@ -71,23 +87,22 @@ function formatSeries(series: TimeSeries): string { details.push("Results include sampled data."); } - const header = renderMarkdown(details.join("\n\n")); + const caption = renderMarkdown(details.join(chart ? " · " : "\n\n")); if (series.values.length === 0) { - return `${header}\n\nNo time buckets returned.`; + return `${caption}\n\nNo time buckets returned.`; } - const chart = formatExploreTimeSeriesChart(series); if (!chart) { - return `${header}\n\n${formatBuckets(series, series.values)}`; + return `${caption}\n\n${formatBuckets(series, series.values)}`; } const gaps = series.values.filter( (bucket) => bucket.value === null || bucket.incomplete, ); const gapDetails = gaps.length > 0 - ? `\n\n${renderMarkdown("Gaps are missing or incomplete buckets, listed below. Complete buckets have at least a baseline tick, including zeros.")}\n\n${formatBuckets(series, gaps)}` + ? `\n\n${renderMarkdown("Missing or incomplete buckets:")}\n\n${formatBuckets(series, gaps)}` : ""; - return `${header}\n\n${chart}${gapDetails}`; + return `${chart}\n${caption}${gapDetails}`; } /** Use the same typed cells for the full fallback and the chart's gap details. */ @@ -128,7 +143,7 @@ export function formatExploreTimeSeriesHuman( ): string { const scope = data.project ? `${data.org}/${data.project}` : data.org; const details = [ - `Querying ${escapeMarkdownInline(data.dataset)} in ${escapeMarkdownInline(scope)}:`, + `Querying ${escapeMarkdownInline(data.dataset)} in ${escapeMarkdownInline(scope)}`, ]; if (data.meta?.start !== undefined && data.meta.end !== undefined) { details.push( @@ -137,6 +152,7 @@ export function formatExploreTimeSeriesHuman( } const ingestion = data.meta?.ingestion; + const ingestionDetails: string[] = []; const hasPendingIngestion = data.timeSeries.some((series) => series.values.some( (bucket) => @@ -144,21 +160,26 @@ export function formatExploreTimeSeriesHuman( ), ); if (ingestion && (hasPendingIngestion || ingestion.status === "stalled")) { - details.push( + ingestionDetails.push( `Ingestion status: ${escapeMarkdownInline(ingestion.status)}.`, ); if (ingestion.delaySeconds != null) { - details.push( + ingestionDetails.push( `Reported ingestion delay: ${formatDurationCompact(ingestion.delaySeconds)}.`, ); } } - const header = renderMarkdown(details.join("\n\n")); - if (data.timeSeries.length === 0) { - return `${header}\n\nNo time series returned.`; + const sections = [ + renderMarkdown(details.join(" · ")), + data.timeSeries.length === 0 + ? "No time series returned." + : data.timeSeries.map(formatSeries).join("\n\n"), + ]; + if (ingestionDetails.length > 0) { + sections.push(renderMarkdown(ingestionDetails.join(" · "))); } - return `${header}\n\n${data.timeSeries.map(formatSeries).join("\n\n")}`; + return sections.join("\n\n"); } /** diff --git a/packages/cli/test/lib/formatters/explore-timeseries.test.ts b/packages/cli/test/lib/formatters/explore-timeseries.test.ts index 4657a2e1a..e8bad06b4 100644 --- a/packages/cli/test/lib/formatters/explore-timeseries.test.ts +++ b/packages/cli/test/lib/formatters/explore-timeseries.test.ts @@ -287,7 +287,7 @@ describe("Explore time-series graphics", () => { }); test.each(["kitty", "sixel"] as const)( - "renders one %s chart and scale per aggregate", + "renders one %s chart per aggregate before its caption without repeating its labels", (format) => { vi.mocked(terminal.selectGraphicsFormat).mockReturnValue(format); vi.stubEnv("TZ", "America/Los_Angeles"); @@ -311,15 +311,26 @@ describe("Explore time-series graphics", () => { const output = formatExploreTimeSeriesHuman(data); - expect( - output.split(format === "kitty" ? "\x1b_Ga=T" : "\x1bP"), - ).toHaveLength(3); - expect(output).toContain("count() over time"); - expect(output).toContain("p95(span.duration) over time"); - expect(output).toContain("Scale: 0 to 1,234"); - expect(output).toContain("Scale: 0 to 25ms"); - expect(output).toContain("Unit: millisecond"); - expect(output).toContain("2026-01-01 00:00:00 to 2026-01-01 00:00:15"); + const imageStart = format === "kitty" ? "\x1b_Ga=T" : "\x1bP"; + expect(output.split(imageStart)).toHaveLength(3); + expect(output.indexOf(imageStart)).toBeLessThan( + output.indexOf("Interval: 5m"), + ); + expect(output.indexOf("Interval: 5m")).toBeLessThan( + output.lastIndexOf(imageStart), + ); + expect(output.lastIndexOf(imageStart)).toBeLessThan( + output.indexOf("Interval: 15s"), + ); + expect(output).not.toContain("count()"); + expect(output).not.toContain("p95(span.duration)"); + expect(output).not.toContain("Scale:"); + expect(output).not.toContain("Unit:"); + expect(output).not.toContain("Bucket starts"); + // The query window remains distinct from the last bucket start on the axis. + expect(output).toContain( + "Range (UTC): 2026-01-01 00:00:00 to 2026-01-01 00:15:00", + ); expect(output).not.toContain("TIME (UTC)"); expect(data).toEqual(original); if (format === "kitty") { @@ -337,13 +348,52 @@ describe("Explore time-series graphics", () => { expect(textPixels(durationImage!, 8, 56, 17)).toEqual( labelPixels("p95 span.duration", [224, 224, 224]), ); - expect( - Array.from(durationImage!.data.slice(58 * 640 * 4, 58 * 640 * 4 + 4)), - ).toEqual([117, 83, 255, 255]); + expect([ + ...durationImage!.data.subarray(58 * 640 * 4, 58 * 640 * 4 + 4), + ]).toEqual([117, 83, 255, 255]); } }, ); + test.each([ + "p95(custom.measurement.with.a.long.name)", + "count_if(span.op,equals,http.client)", + "equation|count()/2", + "count()/2", + "sum(value)", + ])( + "retains the complete expression %s below an abbreviated legend", + (yAxis) => { + const data = makeData(); + data.timeSeries[0]!.yAxis = yAxis; + + const output = formatExploreTimeSeriesHuman(data); + + expect(kittyImages(output)).toHaveLength(1); + expect(output).toContain(yAxis); + expect(output.indexOf(yAxis)).toBeGreaterThan( + output.indexOf("\x1b_Ga=T"), + ); + expect(output.indexOf(yAxis)).toBeLessThan( + output.indexOf("Interval: 5m"), + ); + }, + ); + + test("retains rate units below a chart with a unitless numeric axis", () => { + const data = makeData(); + data.timeSeries[0]!.meta.valueType = "rate"; + data.timeSeries[0]!.meta.valueUnit = "1/second"; + + const output = formatExploreTimeSeriesHuman(data); + + expect(kittyImages(output)).toHaveLength(1); + expect(output).toContain("Interval: 5m · Unit: 1/second"); + expect(output.indexOf("Unit: 1/second")).toBeGreaterThan( + output.indexOf("\x1b_Ga=T"), + ); + }); + test("keeps gaps in place, distinguishes zero, and retains incomplete values and warnings", () => { const data = makeData(); const series = data.timeSeries[0]!; @@ -385,8 +435,8 @@ describe("Explore time-series graphics", () => { // A small positive value must survive a large peak, just as zero does. expect(alphaAt(image, 610, 46)).toBe(255); expect(alphaAt(image, 610, 45)).toBe(0); - expect(output).toContain("Scale: 0 to 10,000"); - expect(output).toContain("Gaps are missing or incomplete buckets"); + expect(textPixels(image, 0, 0, 6)).toEqual(labelPixels("10,000")); + expect(output).toContain("Missing or incomplete buckets:"); expect(output).toMatch(/2026-01-01 00:05:00\s+│\s+—/); expect(output).toMatch( /2026-01-01 00:15:00\s+│\s+90,000\s+│\s+Outside retention/, @@ -401,6 +451,20 @@ describe("Explore time-series graphics", () => { expect(output).toContain("Some buckets have low confidence"); expect(output).toContain("Results include sampled data"); expect(output).toContain("Reported ingestion delay: 4m 48s"); + const caption = output.slice( + output.indexOf("Interval: 5m"), + output.indexOf("Missing or incomplete buckets:"), + ); + expect(caption.trim()).not.toContain("\n\n"); + expect(output.indexOf("Interval: 5m")).toBeGreaterThan( + output.indexOf("\x1b_Ga=T"), + ); + expect( + output.indexOf("Only part of the available data was scanned"), + ).toBeGreaterThan(output.indexOf("\x1b_Ga=T")); + expect(output.indexOf("Ingestion status: healthy")).toBeGreaterThan( + output.indexOf("Window not elapsed"), + ); expect(data).toEqual(original); }); @@ -418,7 +482,6 @@ describe("Explore time-series graphics", () => { const output = formatExploreTimeSeriesHuman(data); const [image] = kittyImages(output); - expect(output).toContain("Scale: 0 to 0.002ms"); expect(image).toBeDefined(); expect(textPixels(image!, 0, 0, 7)).toEqual(labelPixels("0.002ms")); expect(alphaAt(image!, 100, 22)).toBe(0); diff --git a/packages/cli/test/lib/formatters/sixel-dashboard.test.ts b/packages/cli/test/lib/formatters/sixel-dashboard.test.ts index 7d7a9d63e..041af289b 100644 --- a/packages/cli/test/lib/formatters/sixel-dashboard.test.ts +++ b/packages/cli/test/lib/formatters/sixel-dashboard.test.ts @@ -109,14 +109,23 @@ describe("dashboard chart rendering", () => { }); test("uses compact aggregate names in the graphics legend", () => { - expect(formatLegendLabel("p50(span.duration)")).toBe("p50 span.duration"); - expect(formatLegendLabel("p95(value,web.vital,distribution,none)")).toBe( - "p95 web.vital", + expect(formatLegendLabel("p50(span.duration)")).toEqual({ + text: "p50 span.duration", + abbreviated: false, + }); + expect(formatLegendLabel("p95(value,web.vital,distribution,none)")).toEqual( + { + text: "p95 web.vital", + abbreviated: true, + }, ); expect( formatLegendLabel("p95(value,backend.duration,distribution,none)"), - ).toBe("p95 backend.duration"); - expect(formatLegendLabel("GET /api/projects")).toBe("GET /api/projects"); + ).toEqual({ text: "p95 backend.dur...", abbreviated: true }); + expect(formatLegendLabel("GET /api/projects")).toEqual({ + text: "GET /api/projects", + abbreviated: false, + }); }); test("centers the no-data state inside a chart widget", () => { From c8bfc027495f53b7ed8af38c9a5174c223240205 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 30 Oct 2026 10:43:25 +0100 Subject: [PATCH 3/3] fix(cli): avoid repeated units on tiny explore charts --- .../formatters/explore-timeseries-chart.ts | 4 +- .../lib/formatters/explore-timeseries.test.ts | 38 +++++++++++++------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/lib/formatters/explore-timeseries-chart.ts b/packages/cli/src/lib/formatters/explore-timeseries-chart.ts index 28fbf8016..a9c33ee9b 100644 --- a/packages/cli/src/lib/formatters/explore-timeseries-chart.ts +++ b/packages/cli/src/lib/formatters/explore-timeseries-chart.ts @@ -124,7 +124,9 @@ function formatMaximum(value: number, series: TimeSeries): string { if (valueType === "percentage") { return `${value * 100}%`; } - return appendUnitSuffix(String(value), valueUnit); + return valueType === "duration" || valueType === "size" + ? appendUnitSuffix(String(value), valueUnit) + : String(value); } return formatCellValue(value, valueType, valueUnit); } diff --git a/packages/cli/test/lib/formatters/explore-timeseries.test.ts b/packages/cli/test/lib/formatters/explore-timeseries.test.ts index e8bad06b4..3e0a84936 100644 --- a/packages/cli/test/lib/formatters/explore-timeseries.test.ts +++ b/packages/cli/test/lib/formatters/explore-timeseries.test.ts @@ -380,19 +380,35 @@ describe("Explore time-series graphics", () => { }, ); - test("retains rate units below a chart with a unitless numeric axis", () => { - const data = makeData(); - data.timeSeries[0]!.meta.valueType = "rate"; - data.timeSeries[0]!.meta.valueUnit = "1/second"; + test.each([ + { valueType: "rate", maximum: 1234, label: "1,234" }, + { valueType: "rate", maximum: 0.002, label: "0.002" }, + { valueType: "number", maximum: 0.002, label: "0.002" }, + ])( + "keeps $valueType units in the caption on a narrow chart (maximum $maximum)", + ({ valueType, maximum, label }) => { + const data = makeData(); + const series = data.timeSeries[0]!; + series.meta.valueType = valueType; + series.meta.valueUnit = "1/second"; + series.values[0]!.value = maximum; + // A redundant axis unit would consume the space needed by the UTC labels. + process.stdout.columns = 56; + vi.mocked(terminal.terminalPixelWidth).mockReturnValue(448); - const output = formatExploreTimeSeriesHuman(data); + const output = formatExploreTimeSeriesHuman(data); + const images = kittyImages(output); - expect(kittyImages(output)).toHaveLength(1); - expect(output).toContain("Interval: 5m · Unit: 1/second"); - expect(output.indexOf("Unit: 1/second")).toBeGreaterThan( - output.indexOf("\x1b_Ga=T"), - ); - }); + expect(images).toHaveLength(1); + expect(textPixels(images[0]!, 0, 0, label.length)).toEqual( + labelPixels(label), + ); + expect(output).toContain("Interval: 5m · Unit: 1/second"); + expect(output.indexOf("Unit: 1/second")).toBeGreaterThan( + output.indexOf("\x1b_Ga=T"), + ); + }, + ); test("keeps gaps in place, distinguishes zero, and retains incomplete values and warnings", () => { const data = makeData();