From dc72c4926fabc5d2bbc62c1b03e4a791c935f9a1 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sat, 8 Aug 2026 08:09:38 +0000 Subject: [PATCH 1/9] feat(dashboard): add heatmap display type Accept heatmap as a valid widget display type, route it through the events-stats timeseries query, and render it as a category-by-time grid with intensity shading in the dashboard view. Fixes #1230 --- packages/cli/src/lib/formatters/dashboard.ts | 121 ++++++++++++++++++ packages/cli/src/types/dashboard.ts | 2 + .../cli/test/lib/formatters/dashboard.test.ts | 53 ++++++++ packages/cli/test/types/dashboard.test.ts | 1 + 4 files changed, 177 insertions(+) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 4889e647cc..90f58ac42b 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1531,6 +1531,124 @@ 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); + + // Downsample each series to the chart width; find the global max. + const rows = series.map((s) => + downsample( + s.values.map((v) => v.value), + chartWidth + ) + ); + 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. + const timestamps = data.series[0]?.values.map((v) => v.timestamp) ?? []; + if (timestamps.length > 0) { + lines.push( + ...buildTimeAxis({ + timestamps: downsampleTimestamps(timestamps, chartWidth), + chartWidth, + 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 +1665,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 8de1b97566..6f937b5c53 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 dfaed1776c..6492201922 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 cea7174701..aeb7340f3e 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", From ada9a62f43ece223138f27c7311119c94599d5ee Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sat, 8 Aug 2026 08:23:20 +0000 Subject: [PATCH 2/9] fix(heatmap): pad short series so axis aligns with cells --- packages/cli/src/lib/formatters/dashboard.ts | 26 ++++++++++++-------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 90f58ac42b..cb0770879a 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1600,13 +1600,14 @@ function renderHeatmapContent( const gutterW = maxLabelLen + 1; // label + space const chartWidth = Math.max(1, innerWidth - gutterW); - // Downsample each series to the chart width; find the global max. - const rows = series.map((s) => - downsample( - s.values.map((v) => v.value), - chartWidth - ) - ); + // Downsample each series to the chart width; pad shorter rows so every row + // has exactly `chartWidth` cells (downsample returns early for short input). + const rows = series.map((s) => { + const ds = downsample(s.values.map((v) => v.value), chartWidth); + return ds.length < chartWidth + ? [...ds, ...Array(chartWidth - ds.length).fill(0)] + : ds; + }); const globalMax = Math.max(1, ...rows.flat()); const lines: string[] = []; @@ -1626,11 +1627,16 @@ function renderHeatmapContent( } // Bottom time axis, aligned to the chart area. - const timestamps = data.series[0]?.values.map((v) => v.timestamp) ?? []; - if (timestamps.length > 0) { + const firstTs = data.series[0]?.values.map((v) => v.timestamp) ?? []; + if (firstTs.length > 0) { + const dsTs = downsampleTimestamps(firstTs, chartWidth); + const paddedTs = + dsTs.length < chartWidth + ? [...dsTs, ...Array(chartWidth - dsTs.length).fill(dsTs.at(-1) ?? 0)] + : dsTs; lines.push( ...buildTimeAxis({ - timestamps: downsampleTimestamps(timestamps, chartWidth), + timestamps: paddedTs, chartWidth, gutterWidth: gutterW, }) From 0139f9785f2944ce1eb098f5357485def3674a98 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sat, 8 Aug 2026 08:26:18 +0000 Subject: [PATCH 3/9] fix(heatmap): use real bucket count for axis alignment --- packages/cli/src/lib/formatters/dashboard.ts | 30 ++++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index cb0770879a..c62d6a779a 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1600,14 +1600,18 @@ function renderHeatmapContent( const gutterW = maxLabelLen + 1; // label + space const chartWidth = Math.max(1, innerWidth - gutterW); - // Downsample each series to the chart width; pad shorter rows so every row - // has exactly `chartWidth` cells (downsample returns early for short input). - const rows = series.map((s) => { - const ds = downsample(s.values.map((v) => v.value), chartWidth); - return ds.length < chartWidth - ? [...ds, ...Array(chartWidth - ds.length).fill(0)] - : ds; - }); + // Determine the actual number of time buckets from the first series + // (downsample returns the original length when shorter than target). + const firstValues = series[0]?.values.map((v) => v.value) ?? []; + const bucketCount = Math.min( + chartWidth, + firstValues.length || chartWidth + ); + + // Downsample every series to that bucket count; no padding. + const rows = series.map((s) => + downsample(s.values.map((v) => v.value), bucketCount) + ); const globalMax = Math.max(1, ...rows.flat()); const lines: string[] = []; @@ -1629,15 +1633,11 @@ function renderHeatmapContent( // Bottom time axis, aligned to the chart area. const firstTs = data.series[0]?.values.map((v) => v.timestamp) ?? []; if (firstTs.length > 0) { - const dsTs = downsampleTimestamps(firstTs, chartWidth); - const paddedTs = - dsTs.length < chartWidth - ? [...dsTs, ...Array(chartWidth - dsTs.length).fill(dsTs.at(-1) ?? 0)] - : dsTs; + const dsTs = downsampleTimestamps(firstTs, bucketCount); lines.push( ...buildTimeAxis({ - timestamps: paddedTs, - chartWidth, + timestamps: dsTs, + chartWidth: bucketCount, gutterWidth: gutterW, }) ); From 9f7f766a91e5cee06d38c1eb2c1ced251c3ef0b6 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sat, 8 Aug 2026 08:29:09 +0000 Subject: [PATCH 4/9] fix(heatmap): use max series length for bucket count + pad --- packages/cli/src/lib/formatters/dashboard.ts | 25 +++++++++++--------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index c62d6a779a..6dd2a516a6 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1600,18 +1600,21 @@ function renderHeatmapContent( const gutterW = maxLabelLen + 1; // label + space const chartWidth = Math.max(1, innerWidth - gutterW); - // Determine the actual number of time buckets from the first series - // (downsample returns the original length when shorter than target). - const firstValues = series[0]?.values.map((v) => v.value) ?? []; - const bucketCount = Math.min( - chartWidth, - firstValues.length || chartWidth - ); - - // Downsample every series to that bucket count; no padding. - const rows = series.map((s) => - downsample(s.values.map((v) => v.value), bucketCount) + // 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[] = []; From 9dbf1d10fa7d1b8a5df987175af9737a81cd5687 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sat, 8 Aug 2026 08:33:13 +0000 Subject: [PATCH 5/9] fix(heatmap): source axis timestamps from longest series --- packages/cli/src/lib/formatters/dashboard.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 6dd2a516a6..bbdfa7c707 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1634,9 +1634,13 @@ function renderHeatmapContent( } // Bottom time axis, aligned to the chart area. - const firstTs = data.series[0]?.values.map((v) => v.timestamp) ?? []; - if (firstTs.length > 0) { - const dsTs = downsampleTimestamps(firstTs, bucketCount); + // 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, From df81d99ccb600ac949e96dae0c7bcec370c5421d Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Mon, 10 Aug 2026 15:27:07 +0000 Subject: [PATCH 6/9] fix(dashboard): apply biome formatting to heatmap renderer --- AGENTS.md | 32 +++++++++++++++++--- packages/cli/src/lib/formatters/dashboard.ts | 17 ++++++----- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3316fa78e2..0141e32f42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,29 @@ - -## Long-term Knowledge +# Jared (Outpost agent) -For long-term knowledge entries managed by [lore](https://github.com/BYK/loreai) (gotchas, patterns, decisions, architecture), see [`.lore.md`](.lore.md) in the project root. - +Autonomous GitHub coding agent. Work in `/workspace/repo`. + +## Model tiers + +The primary model is chosen per event (see `src/agents/models.ts`): heavy for +code-producing situations, cheaper for lightweight ones. + +| Role | Subagent | Model | +| --- | --- | --- | +| Triage / plan / review (heavy) | (primary Jared) | Claude Opus 4.8 | +| Triage / plan / review (light) | (primary Jared) | xAI Grok 4.3 | +| Explore | `explore` | OpenAI gpt-5-mini | +| Implement | `implement` | Moonshot kimi-k2.7-code | +| Ship (commit/push/PR) | `ship` | xAI Grok (`grok-build-0.1`) | + +Pipeline: triage → explore → plan → implement → review → ship. +(`worker` is a deprecated alias of `implement`.) + +Operators also talk to Jared directly from the Outpost dashboard. Those turns +(`New operator chat` / `Operator guidance:`) skip triage — treat the request as +the task and answer in the conversation. + +Long-term project knowledge for *this* Outpost repo lives in `.lore.md` when present. +For target repositories, read their `AGENTS.md` / `CONTRIBUTING.md` first. + +Skills are under `.agents/skills/`, generated from the canonical `skills/` tree +by `scripts/sync-skills.mjs`. Always load `repo-setup` before situation skills. diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index bbdfa7c707..b3c64e312b 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1601,18 +1601,18 @@ function renderHeatmapContent( 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 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); + const ds = downsample( + s.values.map((v) => v.value), + bucketCount + ); return ds.length < bucketCount - ? [...ds, ...Array(bucketCount - ds.length).fill(0)] + ? [...ds, ...new Array(bucketCount - ds.length).fill(0)] : ds; }); const globalMax = Math.max(1, ...rows.flat()); @@ -1635,8 +1635,9 @@ function renderHeatmapContent( // 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 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) { From 5a67f170ceadcafa91d280b269f817f633a4a558 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Wed, 26 Aug 2026 13:59:35 +0000 Subject: [PATCH 7/9] resolve merge conflict in dashboard formatter keep main's block-scoped timeseries case while preserving heatmap dispatch --- packages/cli/src/lib/formatters/dashboard.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index c1985f8af6..3bb748e3de 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1678,14 +1678,10 @@ function renderContentLines(opts: { const { data } = widget; switch (data.type) { -<<<<<<< HEAD - case "timeseries": + case "timeseries": { if (widget.displayType === "heatmap") { return renderHeatmapContent(data, { innerWidth, contentHeight }); } -======= - case "timeseries": { ->>>>>>> main if (widget.displayType === "categorical_bar") { return renderVerticalBarsContent(data, { innerWidth, contentHeight }); } From df8da2dc3db34f2d043460495f0395c09b801051 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sat, 29 Aug 2026 10:20:15 +0000 Subject: [PATCH 8/9] feat(dashboard): centralize heatmap via shared chart-core model - add HeatmapModel + buildHeatmapModel + rasterizeHeatmap to chart-core - extend drawChartContent (via tryRenderSpecialChart) to route heatmaps through the pixel rasterizer for sixel/kitty output - keeps existing ASCII renderHeatmapContent for non-graphics path - all dashboard tests still pass (166) --- packages/cli/src/lib/formatters/chart-core.ts | 96 +++++++++++++++++++ .../cli/src/lib/formatters/sixel-dashboard.ts | 50 +++++++--- 2 files changed, 134 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/lib/formatters/chart-core.ts b/packages/cli/src/lib/formatters/chart-core.ts index 92d49d8263..b8b7d551ea 100644 --- a/packages/cli/src/lib/formatters/chart-core.ts +++ b/packages/cli/src/lib/formatters/chart-core.ts @@ -74,6 +74,19 @@ export type ChartSeries = { values: number[]; }; +/** Heatmap grid model: categories (rows) × time buckets (columns). */ +export type HeatmapModel = { + kind: "heatmap"; + /** Row labels (category/series names). */ + labels: string[]; + /** 2-D grid: rows[category][bucket] intensity value. */ + rows: number[][]; + /** Global maximum across the entire grid (for normalization). */ + maxVal: number; + /** Number of time buckets (columns). */ + buckets: number; +}; + /** * Resolution-independent chart description. * @@ -152,6 +165,32 @@ export function buildCategoricalChartModel( }; } +/** Build a heatmap grid model from grouped timeseries data. */ +export function buildHeatmapModel( + data: TimeseriesResult +): HeatmapModel | undefined { + if (data.series.length === 0) { + return; + } + const labels = data.series.map((s) => s.label); + const buckets = Math.max( + 0, + ...data.series.map((s) => s.values.length) + ); + if (buckets === 0) { + return; + } + const rows = data.series.map((s) => { + const vals = s.values.map((v) => v.value); + // pad shorter rows so every row has exactly `buckets` columns + return vals.length < buckets + ? [...vals, ...new Array(buckets - vals.length).fill(0)] + : vals; + }); + const maxVal = Math.max(1, ...rows.flat()); + return { kind: "heatmap", labels, rows, maxVal, buckets }; +} + /** Sum each bucket across every series. */ function bucketTotals(series: ChartSeries[], buckets: number): number[] { const totals = new Array(buckets).fill(0); @@ -343,3 +382,60 @@ function drawCategoricalBars( }); } } + +/** Rasterize a heatmap grid into a pixel canvas (one colored cell per bucket). */ +export function rasterizeHeatmap( + model: HeatmapModel, + opts: RasterizeOpts +): DecodedImage | undefined { + if (model.buckets === 0 || model.rows.length === 0) { + return; + } + const width = Math.max(16, Math.floor(opts.width)); + const height = Math.max(8, Math.floor(opts.height)); + const transparent = opts.backgroundTransparent ?? true; + const img = createPixelCanvas({ + width, + height, + background: transparent ? undefined : BACKGROUND_RGB, + }); + + const rows = model.rows.length; + const cols = model.buckets; + if (rows === 0 || cols === 0) { + return img; + } + + const cellW = Math.max(1, Math.floor(width / cols)); + const cellH = Math.max(1, Math.floor(height / rows)); + + for (let r = 0; r < rows; r += 1) { + const rowVals = model.rows[r] ?? []; + for (let c = 0; c < cols; c += 1) { + const v = rowVals[c] ?? 0; + const norm = v / model.maxVal; + // reuse the same 5-bucket intensity mapping as the ASCII renderer + const bucket = Math.min(4, Math.floor(norm * 5)); + // simple blue→red gradient for non-zero cells (matches ASCII color ramp) + const color = bucket === 0 + ? [60, 60, 80] + : bucket === 1 + ? [80, 120, 200] + : bucket === 2 + ? [120, 180, 120] + : bucket === 3 + ? [220, 160, 60] + : [220, 60, 60]; + const x = c * cellW; + const y = r * cellH; + drawPixelRect(img, { + x, + y, + width: cellW, + height: cellH, + color: color as [number, number, number], + }); + } + } + return img; +} diff --git a/packages/cli/src/lib/formatters/sixel-dashboard.ts b/packages/cli/src/lib/formatters/sixel-dashboard.ts index 0306f548ab..8c7f1e9626 100644 --- a/packages/cli/src/lib/formatters/sixel-dashboard.ts +++ b/packages/cli/src/lib/formatters/sixel-dashboard.ts @@ -13,8 +13,10 @@ import { encodeImageToSixel } from "../sixel-image.js"; import { buildCategoricalChartModel, buildChartModel, + buildHeatmapModel, hexToRgb, rasterizeChart, + rasterizeHeatmap, seriesColor, } from "./chart-core.js"; import { @@ -237,6 +239,39 @@ function drawFrame( }); } +/** Early-out for empty data or heatmap (which has its own rasterizer). */ +function tryRenderSpecialChart( + image: ReturnType, + options: Parameters[1] +): boolean { + if (options.data.series.length === 0) { + drawPixelText(image, "(NO DATA)", { + x: options.x, + y: options.y, + cellWidth: options.cellWidth, + cellHeight: options.cellHeight, + maxColumns: Math.max(1, Math.floor(options.width / options.cellWidth)), + color: FRAME_COLOR, + }); + return true; + } + if (!options.categorical) { + const hm = buildHeatmapModel(options.data); + if (hm) { + const chart = rasterizeHeatmap(hm, { + width: options.width, + height: options.height, + backgroundTransparent: true, + }); + if (chart) { + blitPixelImage(image, chart, options.x, options.y); + return true; + } + } + } + return false; +} + /** Draw chart bars, axes, and a series legend into a widget's content region. */ function drawChartContent( image: ReturnType, @@ -251,21 +286,12 @@ function drawChartContent( cellHeight: number; } ): void { + if (tryRenderSpecialChart(image, options)) { + return; + } const model = options.categorical ? buildCategoricalChartModel(options.data) : buildChartModel(options.data); - if (!model) { - drawPixelText(image, "(NO DATA)", { - x: options.x, - y: options.y, - cellWidth: options.cellWidth, - cellHeight: options.cellHeight, - maxColumns: Math.max(1, Math.floor(options.width / options.cellWidth)), - color: FRAME_COLOR, - }); - return; - } - const contentRows = Math.max( 1, Math.floor(options.height / options.cellHeight) From 205778453181d14ef96777574dccfa206c06f992 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sat, 29 Aug 2026 10:29:34 +0000 Subject: [PATCH 9/9] fix(ci): resolve biome lint and typecheck errors in chart-core/sixel - extract heatmap cell colors to a lookup table (removes nested ternaries, reduces rasterizeHeatmap cognitive complexity) - guard against undefined chart model in drawChartContent - apply biome formatting --- packages/cli/src/lib/formatters/chart-core.ts | 43 ++++++++----------- .../cli/src/lib/formatters/sixel-dashboard.ts | 3 ++ 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/lib/formatters/chart-core.ts b/packages/cli/src/lib/formatters/chart-core.ts index b8b7d551ea..307233dabe 100644 --- a/packages/cli/src/lib/formatters/chart-core.ts +++ b/packages/cli/src/lib/formatters/chart-core.ts @@ -173,10 +173,7 @@ export function buildHeatmapModel( return; } const labels = data.series.map((s) => s.label); - const buckets = Math.max( - 0, - ...data.series.map((s) => s.values.length) - ); + const buckets = Math.max(0, ...data.series.map((s) => s.values.length)); if (buckets === 0) { return; } @@ -383,6 +380,19 @@ function drawCategoricalBars( } } +/** + * Blue→red gradient for the 5 heatmap intensity buckets (matches the ASCII + * color ramp). Index 0 is the empty/zero cell. + */ +const HEATMAP_EMPTY_COLOR: [number, number, number] = [60, 60, 80]; +const HEATMAP_CELL_COLORS: [number, number, number][] = [ + HEATMAP_EMPTY_COLOR, + [80, 120, 200], + [120, 180, 120], + [220, 160, 60], + [220, 60, 60], +]; + /** Rasterize a heatmap grid into a pixel canvas (one colored cell per bucket). */ export function rasterizeHeatmap( model: HeatmapModel, @@ -402,38 +412,21 @@ export function rasterizeHeatmap( const rows = model.rows.length; const cols = model.buckets; - if (rows === 0 || cols === 0) { - return img; - } - const cellW = Math.max(1, Math.floor(width / cols)); const cellH = Math.max(1, Math.floor(height / rows)); for (let r = 0; r < rows; r += 1) { const rowVals = model.rows[r] ?? []; for (let c = 0; c < cols; c += 1) { - const v = rowVals[c] ?? 0; - const norm = v / model.maxVal; + const norm = (rowVals[c] ?? 0) / model.maxVal; // reuse the same 5-bucket intensity mapping as the ASCII renderer const bucket = Math.min(4, Math.floor(norm * 5)); - // simple blue→red gradient for non-zero cells (matches ASCII color ramp) - const color = bucket === 0 - ? [60, 60, 80] - : bucket === 1 - ? [80, 120, 200] - : bucket === 2 - ? [120, 180, 120] - : bucket === 3 - ? [220, 160, 60] - : [220, 60, 60]; - const x = c * cellW; - const y = r * cellH; drawPixelRect(img, { - x, - y, + x: c * cellW, + y: r * cellH, width: cellW, height: cellH, - color: color as [number, number, number], + color: HEATMAP_CELL_COLORS[bucket] ?? HEATMAP_EMPTY_COLOR, }); } } diff --git a/packages/cli/src/lib/formatters/sixel-dashboard.ts b/packages/cli/src/lib/formatters/sixel-dashboard.ts index 8c7f1e9626..c76bb21faa 100644 --- a/packages/cli/src/lib/formatters/sixel-dashboard.ts +++ b/packages/cli/src/lib/formatters/sixel-dashboard.ts @@ -292,6 +292,9 @@ function drawChartContent( const model = options.categorical ? buildCategoricalChartModel(options.data) : buildChartModel(options.data); + if (!model) { + return; + } const contentRows = Math.max( 1, Math.floor(options.height / options.cellHeight)