Skip to content
135 changes: 135 additions & 0 deletions packages/cli/src/lib/formatters/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1529,6 +1529,138 @@ 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, ...new 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
jared-outpost[bot] marked this conversation as resolved.
);
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 +1679,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