Skip to content

Commit 18f6f3f

Browse files
fix(dashboard): render sixel widgets as standalone blocks, not in-grid
Address review feedback on the sixel timeseries rendering: - Pull sixel-eligible widgets out of the side-by-side framebuffer and render them full-width after the character grid. A DCS image advances the cursor by many rows, so composing it into a shared terminal row trampled the widget's own borders and any neighbor on those rows. - Exclude categorical_bar from sixel: the chart core only models time-bucket columns, so categorical widgets keep the ASCII per-category renderer instead of being drawn as the wrong viz. - Downsample dense series to the pixel budget in rasterizeChart, so ranges with more buckets than ~half the canvas width no longer clip their tail off-canvas (mirrors the ASCII sparkline path). - Use the shared EscapeType for the local esc state in fitToWidth. Adds tests for the categorical exclusion, standalone-block layout, and dense-series downsampling.
1 parent e2884e4 commit 18f6f3f

4 files changed

Lines changed: 164 additions & 24 deletions

File tree

packages/cli/src/lib/formatters/chart-core.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import type { TimeseriesResult } from "../../types/dashboard.js";
1313
import type { DecodedImage } from "../sixel-image.js";
14+
import { downsample } from "./sparkline.js";
1415

1516
/**
1617
* Chart color palette based on Sentry's categorical chart hues.
@@ -157,18 +158,41 @@ export function rasterizeChart(
157158
return;
158159
}
159160

161+
// Each column needs at least a 1px bar plus a 1px gap, so more buckets than
162+
// ~half the canvas width would push later columns off-canvas and clip them.
163+
// Downsample to fit, mirroring what the ASCII sparkline path already does.
164+
const fitted = fitModelToWidth(model, width);
165+
160166
const img = createCanvas(width, height, opts.backgroundTransparent ?? true);
161-
const layout = computeBarLayout(width, model.buckets);
167+
const layout = computeBarLayout(width, fitted.buckets);
162168

163-
if (model.stacked) {
164-
drawStackedColumns(img, model, height, layout);
169+
if (fitted.stacked) {
170+
drawStackedColumns(img, fitted, height, layout);
165171
} else {
166-
drawBars(img, model, height, layout);
172+
drawBars(img, fitted, height, layout);
167173
}
168174

169175
return img;
170176
}
171177

178+
/**
179+
* Downsample a model's series so the bucket count fits the canvas: with a 1px
180+
* bar and 1px gap each column needs ~2px, so cap buckets at `width / 2`.
181+
* Returns the model unchanged when it already fits.
182+
*/
183+
function fitModelToWidth(model: ChartModel, width: number): ChartModel {
184+
const maxBuckets = Math.max(1, Math.floor(width / 2));
185+
if (model.buckets <= maxBuckets) {
186+
return model;
187+
}
188+
const series = model.series.map((s) => ({
189+
label: s.label,
190+
values: downsample(s.values, maxBuckets),
191+
}));
192+
const buckets = Math.max(...series.map((s) => s.values.length));
193+
return { ...model, series, buckets };
194+
}
195+
172196
/** Create an RGBA canvas, optionally transparent. */
173197
function createCanvas(
174198
width: number,

packages/cli/src/lib/formatters/dashboard.ts

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1533,24 +1533,6 @@ function renderContentLines(opts: {
15331533

15341534
switch (data.type) {
15351535
case "timeseries": {
1536-
// Opt-in sixel image rendering for timeseries widgets.
1537-
const env = getEnv();
1538-
if (
1539-
!isPlainOutput() &&
1540-
(env.SENTRY_DASHBOARD_SIXEL === "1" ||
1541-
widget.displayType === "timeseries_sixel") &&
1542-
canRenderSixel()
1543-
) {
1544-
const pixelBudget = terminalPixelWidth();
1545-
const sixel = renderTimeseriesAsSixel(data, {
1546-
maxPixelWidth: pixelBudget ?? innerWidth * 8,
1547-
maxPixelHeight: contentHeight * 12,
1548-
});
1549-
if (sixel) {
1550-
return [sixel];
1551-
}
1552-
}
1553-
15541536
if (widget.displayType === "categorical_bar") {
15551537
return renderVerticalBarsContent(data, { innerWidth, contentHeight });
15561538
}
@@ -1697,7 +1679,7 @@ function fitToWidth(line: string, targetWidth: number): string {
16971679
// Truncate: walk characters, tracking visible width
16981680
let result = "";
16991681
let width = 0;
1700-
const esc = { type: "none" as "none" | "start" | "csi" | "osc" };
1682+
const esc: { type: EscapeType } = { type: "none" };
17011683
for (const ch of line) {
17021684
if (advanceEscape(esc, ch, result)) {
17031685
result += ch;
@@ -1862,11 +1844,71 @@ export function formatDashboardWithData(data: DashboardViewData): string {
18621844
const termWidth = getTermWidth();
18631845
const lines: string[] = [];
18641846
lines.push(...renderHeader(data, termWidth));
1865-
lines.push(...renderGrid(data.widgets, termWidth));
1847+
1848+
// Sixel widgets can't be composed into the side-by-side framebuffer: a DCS
1849+
// image advances the cursor by many rows, which would trample the widget's
1850+
// own borders and any neighbor sharing its grid rows. Render them full-width
1851+
// and stacked, after the character grid, and keep the grid for the rest.
1852+
const sixelWidgets = data.widgets.filter(isSixelEligible);
1853+
const gridWidgets = sixelWidgets.length
1854+
? data.widgets.filter((w) => !isSixelEligible(w))
1855+
: data.widgets;
1856+
1857+
if (gridWidgets.length > 0) {
1858+
lines.push(...renderGrid(gridWidgets, termWidth));
1859+
}
1860+
for (const w of sixelWidgets) {
1861+
lines.push(...renderSixelWidget(w, termWidth));
1862+
}
1863+
18661864
lines.push("");
18671865
return lines.join("\n");
18681866
}
18691867

1868+
/**
1869+
* Whether a widget should render as an inline sixel image: opt-in is on, the
1870+
* data is a plain (non-categorical) timeseries, and the terminal supports
1871+
* sixel. Categorical bars are excluded — the chart core only models
1872+
* time-bucket columns, so they keep the ASCII per-category renderer.
1873+
*/
1874+
function isSixelEligible(widget: DashboardViewWidget): boolean {
1875+
if (
1876+
widget.data.type !== "timeseries" ||
1877+
widget.displayType === "categorical_bar"
1878+
) {
1879+
return false;
1880+
}
1881+
const env = getEnv();
1882+
const optedIn =
1883+
env.SENTRY_DASHBOARD_SIXEL === "1" ||
1884+
widget.displayType === "timeseries_sixel";
1885+
return optedIn && !isPlainOutput() && canRenderSixel();
1886+
}
1887+
1888+
/**
1889+
* Render a single sixel widget as a full-width block: a title line followed by
1890+
* the inline image. Falls back to the normal bordered character rendering when
1891+
* the image can't be produced (empty data, no drawable pixels).
1892+
*/
1893+
function renderSixelWidget(
1894+
widget: DashboardViewWidget,
1895+
termWidth: number
1896+
): string[] {
1897+
if (widget.data.type !== "timeseries") {
1898+
return renderWidgetLines(widget, termWidth);
1899+
}
1900+
const pixelBudget = terminalPixelWidth();
1901+
const sixel = renderTimeseriesAsSixel(widget.data, {
1902+
maxPixelWidth: pixelBudget ?? termWidth * 8,
1903+
maxPixelHeight: 2 * LINES_PER_UNIT * 12,
1904+
});
1905+
if (!sixel) {
1906+
return renderWidgetLines(widget, termWidth);
1907+
}
1908+
const title = isPlainOutput() ? widget.title : chalk.bold(widget.title);
1909+
return ["", title, sixel];
1910+
}
1911+
18701912
// ---------------------------------------------------------------------------
18711913
// HumanRenderer factory (supports --refresh mode)
18721914
// ---------------------------------------------------------------------------

packages/cli/test/lib/formatters/chart-core.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,31 @@ describe("rasterizeChart", () => {
146146
// Top-left pixel is above the bars, so it shows the background fill.
147147
expect(img?.data[3]).toBe(255);
148148
});
149+
150+
test("downsamples dense series so late buckets stay on canvas", () => {
151+
// Far more buckets than half the canvas width: without downsampling the
152+
// rising tail would be clipped off the right edge. Put all the signal in
153+
// the last quarter of the range so a clipped render would be near-empty.
154+
const values = Array.from({ length: 400 }, (_, i) => ({
155+
timestamp: 1_700_000_000 + i * 60,
156+
value: i < 300 ? 0 : i,
157+
}));
158+
const model = buildChartModel(
159+
makeTimeseries({ series: [{ label: "c", values }] })
160+
);
161+
const width = 64;
162+
const img = rasterizeChart(model!, { width, height: 32 });
163+
164+
// Count opaque pixels in the right quarter — the tail must survive.
165+
let rightOpaque = 0;
166+
const data = img?.data ?? new Uint8Array();
167+
for (let y = 0; y < 32; y++) {
168+
for (let x = Math.floor(width * 0.75); x < width; x++) {
169+
if ((data[(y * width + x) * 4 + 3] ?? 0) > 0) {
170+
rightOpaque += 1;
171+
}
172+
}
173+
}
174+
expect(rightOpaque).toBeGreaterThan(0);
175+
});
149176
});

packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,51 @@ describe("dashboard sixel integration", () => {
143143
expect(output).toContain(`${ESC}P`);
144144
expect(output).toContain(`${ESC}\\`);
145145
});
146+
147+
test("categorical_bar widgets keep the ASCII renderer, not sixel", () => {
148+
const data = makeDashboardData({
149+
widgets: [
150+
makeWidget({
151+
title: "Categorical",
152+
displayType: "categorical_bar",
153+
layout: { x: 0, y: 0, w: 6, h: 2 },
154+
}),
155+
],
156+
});
157+
158+
const output = formatDashboardWithData(data);
159+
expect(output).toContain("Categorical");
160+
// The chart core has no categorical mode, so these must not be rasterized.
161+
expect(output).not.toContain(`${ESC}P`);
162+
});
163+
164+
test("sixel widgets render as a standalone block, keeping the grid intact", () => {
165+
const data = makeDashboardData({
166+
widgets: [
167+
makeWidget({
168+
title: "Big Number",
169+
displayType: "big_number",
170+
data: { type: "scalar", value: 42 },
171+
layout: { x: 0, y: 0, w: 3, h: 2 },
172+
}),
173+
makeWidget({
174+
title: "Sixel Chart",
175+
displayType: "line",
176+
layout: { x: 3, y: 0, w: 3, h: 2 },
177+
}),
178+
],
179+
});
180+
181+
const output = formatDashboardWithData(data);
182+
const lines = output.split("\n");
183+
// The DCS image must not share a terminal row with the neighboring widget's
184+
// borders — the sixel line carries no box-drawing characters.
185+
const sixelLine = lines.find((l) => l.includes(`${ESC}P`));
186+
expect(sixelLine).toBeDefined();
187+
expect(sixelLine).not.toContain("│");
188+
expect(sixelLine).not.toContain("─");
189+
// The character grid (big-number widget) still renders above the image.
190+
expect(output).toContain("Big Number");
191+
expect(output).toContain("Sixel Chart");
192+
});
146193
});

0 commit comments

Comments
 (0)