diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 4889e647c..bbdfa7c70 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1531,6 +1531,137 @@ function renderPlaceholderContent(message: string): string[] { return [isPlainOutput() ? `(${message})` : muted(`(${message})`)]; } +// --------------------------------------------------------------------------- +// Heatmap renderer +// --------------------------------------------------------------------------- + +/** + * Intensity ramp for heatmap cells. + * + * Index 0 is empty (zero value); 1-4 map increasing intensity to shade + * blocks in plain mode and to a blue→red heat gradient in color mode. + */ +const HEATMAP_SHADES = [" ", "░", "▒", "▓", "█"] as const; +const HEATMAP_COLORS = [ + "#79B8FF", // low — cyan/blue + "#FDB81B", // yellow + "#FF9838", // orange + "#fe4144", // high — red +] as const; + +/** Map a normalized intensity (0-1) to a ramp bucket index (0-4). */ +function heatmapBucket(normalized: number): number { + if (normalized <= 0) { + return 0; + } + return Math.min(4, Math.max(1, Math.ceil(normalized * 4))); +} + +/** Render a single heatmap cell for a normalized intensity. */ +function heatmapCell(normalized: number, plain: boolean): string { + const bucket = heatmapBucket(normalized); + if (plain) { + return HEATMAP_SHADES[bucket] ?? " "; + } + if (bucket === 0) { + return " "; + } + const color = HEATMAP_COLORS[bucket - 1] ?? COLORS.magenta; + return chalk.hex(color)("█"); +} + +/** + * Render heatmap content: one row per series (category), columns over time. + * + * Cell intensity encodes the value relative to the global maximum across all + * cells, so hot spots stand out. Falls back to a "no data" line when there + * are no series or values. + */ +function renderHeatmapContent( + data: TimeseriesResult, + opts: { innerWidth: number; contentHeight: number } +): string[] { + const { innerWidth, contentHeight } = opts; + if (data.series.length === 0) { + return [noDataLine()]; + } + + const plain = isPlainOutput(); + + // Reserve rows for the bottom time axis (2 lines) and a legend (1 line). + const axisLines = 3; + const maxRows = Math.max(1, contentHeight - axisLines); + const series = data.series.slice(0, maxRows); + + const maxLabelLen = Math.min( + 20, + Math.max(4, ...series.map((s) => s.label.length)) + ); + const gutterW = maxLabelLen + 1; // label + space + const chartWidth = Math.max(1, innerWidth - gutterW); + + // Determine the actual number of time buckets from the longest series. + const maxLen = Math.max( + 0, + ...series.map((s) => s.values.length) + ); + const bucketCount = Math.min(chartWidth, maxLen || chartWidth); + + // Downsample every series to that bucket count; pad shorter series so every + // row has exactly `bucketCount` cells (downsample returns early for short input). + const rows = series.map((s) => { + const ds = downsample(s.values.map((v) => v.value), bucketCount); + return ds.length < bucketCount + ? [...ds, ...Array(bucketCount - ds.length).fill(0)] + : ds; + }); + const globalMax = Math.max(1, ...rows.flat()); + + const lines: string[] = []; + for (let i = 0; i < series.length; i += 1) { + const s = series[i]; + const values = rows[i] ?? []; + if (!s) { + continue; + } + const label = + s.label.length > maxLabelLen + ? `${s.label.slice(0, maxLabelLen - 1)}…` + : s.label.padEnd(maxLabelLen); + const cells = values.map((v) => heatmapCell(v / globalMax, plain)).join(""); + const labelStr = plain ? label : chalk.hex(COLORS.cyan)(label); + lines.push(`${labelStr} ${cells}`); + } + + // Bottom time axis, aligned to the chart area. + // Find the longest series so its timestamps match the bucketCount. + const longest = series.reduce((a, b) => + (b.values.length > a.values.length ? b : a), series[0] ?? { values: [] } + ); + const axisTs = longest.values.map((v) => v.timestamp); + if (axisTs.length > 0) { + const dsTs = downsampleTimestamps(axisTs, bucketCount); + lines.push( + ...buildTimeAxis({ + timestamps: dsTs, + chartWidth: bucketCount, + gutterWidth: gutterW, + }) + ); + } + + // Intensity legend: low → high. + const gutter = " ".repeat(gutterW); + if (plain) { + lines.push(`${gutter}low ${HEATMAP_SHADES.slice(1).join("")} high`); + } else { + const ramp = HEATMAP_COLORS.map((c) => chalk.hex(c)("█")).join(""); + lines.push(`${gutter}${muted("low")} ${ramp} ${muted("high")}`); + } + + return lines; +} + /** * Dispatch to the appropriate content renderer based on data type. * @@ -1547,6 +1678,9 @@ function renderContentLines(opts: { switch (data.type) { case "timeseries": + if (widget.displayType === "heatmap") { + return renderHeatmapContent(data, { innerWidth, contentHeight }); + } if (widget.displayType === "categorical_bar") { return renderVerticalBarsContent(data, { innerWidth, contentHeight }); } diff --git a/packages/cli/src/types/dashboard.ts b/packages/cli/src/types/dashboard.ts index 8de1b9756..6f937b5c5 100644 --- a/packages/cli/src/types/dashboard.ts +++ b/packages/cli/src/types/dashboard.ts @@ -72,6 +72,7 @@ export const DISPLAY_TYPES = [ "top_n", "details", "categorical_bar", + "heatmap", "wheel", "rage_and_dead_clicks", "server_tree", @@ -1041,6 +1042,7 @@ export const TIMESERIES_DISPLAY_TYPES = new Set([ "stacked_area", "bar", "categorical_bar", + "heatmap", ]); /** Display types that use tabular data (events endpoint) */ diff --git a/packages/cli/test/lib/formatters/dashboard.test.ts b/packages/cli/test/lib/formatters/dashboard.test.ts index dfaed1776..649220192 100644 --- a/packages/cli/test/lib/formatters/dashboard.test.ts +++ b/packages/cli/test/lib/formatters/dashboard.test.ts @@ -605,6 +605,59 @@ describe("formatDashboardWithData", () => { expect(output).toContain("(no data)"); }); + test("renders heatmap as one row per series with a legend", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Errors by Browser", + displayType: "heatmap", + layout: { x: 0, y: 0, w: 3, h: 3 }, + data: makeTimeseriesData({ + series: [ + { + label: "Chrome", + values: [ + { timestamp: 1_700_000_000, value: 0 }, + { timestamp: 1_700_000_060, value: 50 }, + { timestamp: 1_700_000_120, value: 100 }, + ], + }, + { + label: "Firefox", + values: [ + { timestamp: 1_700_000_000, value: 5 }, + { timestamp: 1_700_000_060, value: 10 }, + { timestamp: 1_700_000_120, value: 20 }, + ], + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("Errors by Browser"); + // Category labels appear as row headers + expect(output).toContain("Chrome"); + expect(output).toContain("Firefox"); + // Intensity legend is present + expect(output).toContain("low"); + expect(output).toContain("high"); + }); + + test("shows no data for empty series in heatmap", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "heatmap", + data: makeTimeseriesData({ series: [] }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("(no data)"); + }); + test("bar chart fills full widget width (no trailing gap)", () => { // Create enough data points that bars should fill the chart area. // With a tall widget (h=3), the renderer uses bar mode (not sparkline). diff --git a/packages/cli/test/types/dashboard.test.ts b/packages/cli/test/types/dashboard.test.ts index cea717470..aeb7340f3 100644 --- a/packages/cli/test/types/dashboard.test.ts +++ b/packages/cli/test/types/dashboard.test.ts @@ -93,6 +93,7 @@ describe("DISPLAY_TYPES", () => { "top_n", "details", "categorical_bar", + "heatmap", "wheel", "rage_and_dead_clicks", "server_tree",