Skip to content
Open
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
134 changes: 134 additions & 0 deletions packages/cli/src/lib/formatters/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
);
Comment thread
cursor[bot] marked this conversation as resolved.
}

// 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.
*
Expand All @@ -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 });
}
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/types/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const DISPLAY_TYPES = [
"top_n",
"details",
"categorical_bar",
"heatmap",
"wheel",
"rage_and_dead_clicks",
"server_tree",
Expand Down Expand Up @@ -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) */
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/test/lib/formatters/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions packages/cli/test/types/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ describe("DISPLAY_TYPES", () => {
"top_n",
"details",
"categorical_bar",
"heatmap",
"wheel",
"rage_and_dead_clicks",
"server_tree",
Expand Down
Loading