provide chart for the projections --vs command

This commit is contained in:
Emil Lerch 2026-06-26 17:10:25 -07:00
parent a70e61d873
commit f107002b93
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 520 additions and 15 deletions

View file

@ -31,6 +31,7 @@ const line_chart = @import("charts/line_chart.zig");
const projections = @import("analytics/projections.zig");
const forecast = @import("analytics/forecast_evaluation.zig");
const forecast_chart = @import("charts/forecast_chart.zig");
const compare_chart = @import("charts/compare_chart.zig");
const theme = @import("tui/theme.zig");
/// Default PNG export resolution. Matches `charts/chart.zig`'s
@ -189,6 +190,34 @@ pub fn exportBacktestChart(
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
}
/// Export the `--vs` projection comparison overlay - two percentile-
/// band envelopes ("then" and "now") on one chart. Wraps
/// `compare_chart.renderToSurface` + `writeToPNGFile`.
pub fn exportCompareChart(
io: std.Io,
alloc: std.mem.Allocator,
then_bands: []const projections.YearPercentiles,
now_bands: []const projections.YearPercentiles,
path: []const u8,
) !void {
var rendered = compare_chart.renderToSurface(
io,
alloc,
then_bands,
now_bands,
default_width,
default_height,
theme.default_theme,
true,
) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
else => return err,
};
defer rendered.deinit(alloc);
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
}
// Tests
test "exportSymbolChart writes a non-empty PNG file" {
@ -497,3 +526,59 @@ test "exportBacktestChart returns InsufficientData with a single anchor" {
exportBacktestChart(io, alloc, &anchors, path),
);
}
test "exportCompareChart writes a non-empty PNG file" {
const alloc = std.testing.allocator;
const io = std.testing.io;
var then_bands: [11]projections.YearPercentiles = undefined;
var now_bands: [11]projections.YearPercentiles = undefined;
for (0..11) |i| {
const t: f64 = 1_000_000.0 * (1.0 + 0.05 * @as(f64, @floatFromInt(i)));
const n: f64 = 1_200_000.0 * (1.0 + 0.06 * @as(f64, @floatFromInt(i)));
then_bands[i] = .{ .year = @intCast(i), .p10 = t * 0.6, .p25 = t * 0.8, .p50 = t, .p75 = t * 1.2, .p90 = t * 1.5 };
now_bands[i] = .{ .year = @intCast(i), .p10 = n * 0.6, .p25 = n * 0.8, .p50 = n, .p75 = n * 1.2, .p90 = n * 1.5 };
}
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_compare.png" });
defer alloc.free(path);
try exportCompareChart(io, alloc, &then_bands, &now_bands, path);
var file = try tmp.dir.openFile(io, "test_export_compare.png", .{});
defer file.close(io);
const size = (try file.stat(io)).size;
try std.testing.expect(size > 1024);
var magic: [8]u8 = undefined;
var reader = file.reader(io, &.{});
_ = try reader.interface.readSliceShort(&magic);
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
}
test "exportCompareChart returns InsufficientData with a single-year side" {
const alloc = std.testing.allocator;
const io = std.testing.io;
const then_bands = [_]projections.YearPercentiles{.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }};
var now_bands: [3]projections.YearPercentiles = undefined;
for (0..3) |i| now_bands[i] = .{ .year = @intCast(i), .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 };
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_compare_insufficient.png" });
defer alloc.free(path);
try std.testing.expectError(
error.InsufficientData,
exportCompareChart(io, alloc, &then_bands, &now_bands, path),
);
}

View file

@ -0,0 +1,328 @@
//! Projection comparison chart: overlays two percentile-band
//! envelopes - a "then" projection (computed as of a past snapshot)
//! and a "now" projection - so the viewer can see how the forecast
//! envelope shifted between the two dates.
//!
//! Both projections are aligned at year 0 (each one's own start), so
//! the x-axis is "years from the projection's start" and the overlay
//! answers "how did my projected envelope move between then and now?".
//!
//! Each side draws a light p10-p90 fill plus a solid median line in
//! its own hue (then = `theme.info` / cyan, now = `theme.accent` /
//! purple), keyed by a small top-left "then"/"now" color legend.
//!
//! Visual layers (bottom to top):
//! - Background
//! - Horizontal grid lines
//! - "then" envelope fill, then "now" envelope fill
//! - "then" median, then "now" median (both on top of both fills)
//! - Zero line (if visible)
//! - Panel border
//! - then/now color legend (top-left)
//! - Axis labels (export only)
const std = @import("std");
const z2d = @import("z2d");
const theme = @import("../tui/theme.zig");
const projections = @import("../analytics/projections.zig");
const draw = @import("draw.zig");
const axis = @import("axis.zig");
const text = @import("text.zig");
const Surface = z2d.Surface;
const Context = z2d.Context;
const margin_left: f64 = 4;
const margin_right: f64 = 4;
const margin_top: f64 = 4;
const margin_bottom: f64 = 4;
/// Comparison chart render result (RGB for kitty graphics).
pub const CompareChartResult = struct {
rgb_data: []const u8,
width: u16,
height: u16,
value_min: f64,
value_max: f64,
};
/// Owned by the caller - call `result.deinit(alloc)` when done. The
/// surface seam shared between RGB extraction (kitty) and PNG export.
/// Mirrors `projection_chart.RenderedProjection`.
pub const RenderedCompare = struct {
surface: Surface,
width: u16,
height: u16,
value_min: f64,
value_max: f64,
pub fn deinit(self: *RenderedCompare, alloc: std.mem.Allocator) void {
self.surface.deinit(alloc);
self.* = undefined;
}
pub fn extractRgb(self: *const RenderedCompare, alloc: std.mem.Allocator) ![]u8 {
return draw.extractRgb(alloc, &self.surface);
}
};
/// Render the "then" vs "now" comparison overlay into a `Surface`.
/// Both band slices are aligned at year 0; the x-axis spans the
/// longer of the two horizons. With `axis_labels`, reserves margins
/// and stamps y-axis dollar ticks + x-axis year endpoints (export
/// path); the kitty wrapper passes `false`.
pub fn renderToSurface(
io: std.Io,
alloc: std.mem.Allocator,
then_bands: []const projections.YearPercentiles,
now_bands: []const projections.YearPercentiles,
width_px: u32,
height_px: u32,
th: theme.Theme,
axis_labels: bool,
) !RenderedCompare {
if (then_bands.len < 2 or now_bands.len < 2) return error.InsufficientData;
const w: i32 = @intCast(width_px);
const h: i32 = @intCast(height_px);
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
errdefer sfc.deinit(alloc);
var ctx = Context.init(io, alloc, &sfc);
defer ctx.deinit();
ctx.setAntiAliasingMode(.none);
ctx.setOperator(.src);
const bg = th.bg;
const fwidth: f64 = @floatFromInt(width_px);
const fheight: f64 = @floatFromInt(height_px);
try draw.fillBackground(&ctx, fwidth, fheight, bg);
// Chart area. With axis labels, reserve a right margin for the
// y-axis dollar ticks and a bottom margin for the year endpoints.
const label_scale: i32 = axis.labelScale(h);
const label_char_h: f64 = axis.charHeight(label_scale);
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
const chart_left = m_left;
const chart_right = fwidth - m_right;
const chart_w = chart_right - chart_left;
const chart_top = m_top;
const chart_bottom = fheight - m_bottom;
// Value range across BOTH envelopes (p10 floor, p90 ceiling).
var value_min: f64 = then_bands[0].p10;
var value_max: f64 = then_bands[0].p90;
for (then_bands) |bp| {
if (bp.p10 < value_min) value_min = bp.p10;
if (bp.p90 > value_max) value_max = bp.p90;
}
for (now_bands) |bp| {
if (bp.p10 < value_min) value_min = bp.p10;
if (bp.p90 > value_max) value_max = bp.p90;
}
const pad = (value_max - value_min) * 0.05;
value_min -= pad;
value_max += pad;
if (value_min < 0) value_min = 0;
// X step: align both at year 0; the longer horizon spans the full
// width. `bands[i].year == i`, so index doubles as the year offset.
const n = @max(then_bands.len, now_bands.len);
const x_step = chart_w / @as(f64, @floatFromInt(n - 1));
// Grid lines.
try draw.drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, draw.blendColor(th.text_muted, 40, bg));
// Envelopes: draw BOTH light fills first, then BOTH medians on
// top. Rendering uses the `.src` operator (replace, not blend), so
// drawing a fill after a median would occlude that median - hence
// the two-pass order. "now" fill goes on top of "then" fill.
try drawEnvelopeFill(&ctx, then_bands, chart_left, x_step, value_min, value_max, chart_top, chart_bottom, th.info, bg);
try drawEnvelopeFill(&ctx, now_bands, chart_left, x_step, value_min, value_max, chart_top, chart_bottom, th.accent, bg);
try drawEnvelopeMedian(&ctx, then_bands, chart_left, x_step, value_min, value_max, chart_top, chart_bottom, th.info);
try drawEnvelopeMedian(&ctx, now_bands, chart_left, x_step, value_min, value_max, chart_top, chart_bottom, th.accent);
// Zero line (if visible).
if (value_min <= 0 and value_max > 0) {
const zero_y = draw.mapY(0, value_min, value_max, chart_top, chart_bottom);
try draw.drawHLine(&ctx, chart_left, chart_right, zero_y, draw.blendColor(th.negative, 120, bg), 1.0);
}
// Panel border.
try draw.drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, draw.blendColor(th.border, 80, bg), 1.0);
// Color legend (top-left): keys the two envelope hues. Two
// unlabeled colored medians would otherwise be ambiguous.
{
const lgh: i32 = @intFromFloat(axis.charHeight(label_scale));
const lx: i32 = @as(i32, @intFromFloat(chart_left)) + 4 * label_scale;
const ly: i32 = @as(i32, @intFromFloat(chart_top)) + 2 * label_scale;
text.drawText(&sfc, lx, ly, label_scale, th.info, "then");
text.drawText(&sfc, lx, ly + lgh + 2 * label_scale, label_scale, th.accent, "now");
}
// Axis labels (export only): y dollar ticks + x year endpoints.
if (axis_labels) {
axis.drawYTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, value_min, value_max, 5, .dollars);
var fbuf: [8]u8 = undefined;
var lbuf: [8]u8 = undefined;
const first_s = std.fmt.bufPrint(&fbuf, "{d}", .{0}) catch "0";
const last_s = std.fmt.bufPrint(&lbuf, "{d}", .{n - 1}) catch "";
const yr_y = chart_bottom + axis.labelGap(label_scale);
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, yr_y, first_s, last_s);
}
return .{
.surface = sfc,
.width = @intCast(width_px),
.height = @intCast(height_px),
.value_min = value_min,
.value_max = value_max,
};
}
/// Thin RGB wrapper over `renderToSurface` for the inline kitty path:
/// renders without axis labels, extracts RGB, frees the surface.
pub fn renderCompareChart(
io: std.Io,
alloc: std.mem.Allocator,
then_bands: []const projections.YearPercentiles,
now_bands: []const projections.YearPercentiles,
width_px: u32,
height_px: u32,
th: theme.Theme,
) !CompareChartResult {
var rendered = try renderToSurface(io, alloc, then_bands, now_bands, width_px, height_px, th, false);
defer rendered.deinit(alloc);
return .{
.rgb_data = try rendered.extractRgb(alloc),
.width = rendered.width,
.height = rendered.height,
.value_min = rendered.value_min,
.value_max = rendered.value_max,
};
}
/// Draw one envelope's light p10-p90 fill in `hue`. Indices map to x
/// via `chart_left + i * x_step`.
fn drawEnvelopeFill(
ctx: *Context,
bands: []const projections.YearPercentiles,
chart_left: f64,
x_step: f64,
value_min: f64,
value_max: f64,
chart_top: f64,
chart_bottom: f64,
hue: [3]u8,
bg: [3]u8,
) !void {
ctx.setSourceToPixel(draw.blendColor(hue, 22, bg));
ctx.resetPath();
for (bands, 0..) |bp, i| {
const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
const y = draw.mapY(bp.p90, value_min, value_max, chart_top, chart_bottom);
if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
}
var j: usize = bands.len;
while (j > 0) {
j -= 1;
const x = chart_left + @as(f64, @floatFromInt(j)) * x_step;
const y = draw.mapY(bands[j].p10, value_min, value_max, chart_top, chart_bottom);
try ctx.lineTo(x, y);
}
try ctx.closePath();
try ctx.fill();
}
/// Draw one envelope's solid p50 median line in `hue`.
fn drawEnvelopeMedian(
ctx: *Context,
bands: []const projections.YearPercentiles,
chart_left: f64,
x_step: f64,
value_min: f64,
value_max: f64,
chart_top: f64,
chart_bottom: f64,
hue: [3]u8,
) !void {
ctx.setSourceToPixel(draw.opaqueColor(hue));
ctx.setLineWidth(2.0);
ctx.resetPath();
for (bands, 0..) |bp, i| {
const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
const y = draw.mapY(bp.p50, value_min, value_max, chart_top, chart_bottom);
if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
}
try ctx.stroke();
}
// Tests
fn syntheticBands(buf: []projections.YearPercentiles, base: f64, growth: f64) []projections.YearPercentiles {
for (buf, 0..) |*b, i| {
const v = base * (1.0 + growth * @as(f64, @floatFromInt(i)));
b.* = .{
.year = @intCast(i),
.p10 = v * 0.6,
.p25 = v * 0.8,
.p50 = v,
.p75 = v * 1.2,
.p90 = v * 1.5,
};
}
return buf;
}
test "renderCompareChart produces valid RGB output for two envelopes" {
const alloc = std.testing.allocator;
var then_buf: [11]projections.YearPercentiles = undefined;
var now_buf: [11]projections.YearPercentiles = undefined;
const then_bands = syntheticBands(&then_buf, 1_000_000, 0.05);
const now_bands = syntheticBands(&now_buf, 1_200_000, 0.06);
const th = theme.default_theme;
const result = try renderCompareChart(std.testing.io, alloc, then_bands, now_bands, 240, 120, th);
defer alloc.free(result.rgb_data);
try std.testing.expectEqual(@as(u16, 240), result.width);
try std.testing.expectEqual(@as(u16, 120), result.height);
try std.testing.expectEqual(@as(usize, 240 * 120 * 3), result.rgb_data.len);
try std.testing.expect(result.value_max > result.value_min);
}
test "renderToSurface draws both envelope hues" {
const alloc = std.testing.allocator;
var then_buf: [11]projections.YearPercentiles = undefined;
var now_buf: [11]projections.YearPercentiles = undefined;
const then_bands = syntheticBands(&then_buf, 1_000_000, 0.03);
const now_bands = syntheticBands(&now_buf, 1_500_000, 0.07);
const th = theme.default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, then_bands, now_bands, 200, 100, th, false);
defer rendered.deinit(alloc);
// Both median hues should have painted opaque pixels.
try std.testing.expect(draw.countColor(&rendered.surface, th.info) > 0);
try std.testing.expect(draw.countColor(&rendered.surface, th.accent) > 0);
}
test "renderToSurface insufficient data on a single-year band" {
const alloc = std.testing.allocator;
var then_buf: [1]projections.YearPercentiles = undefined;
var now_buf: [11]projections.YearPercentiles = undefined;
const then_bands = syntheticBands(&then_buf, 1_000_000, 0.05);
const now_bands = syntheticBands(&now_buf, 1_000_000, 0.05);
const th = theme.default_theme;
try std.testing.expectError(
error.InsufficientData,
renderToSurface(std.testing.io, alloc, then_bands, now_bands, 200, 100, th, false),
);
}

View file

@ -10,9 +10,11 @@
//! Solid 1-bit pixels avoid it entirely.
//! 2. It keeps a ~hundreds-of-KB TTF (and its license) out of the repo.
//!
//! The glyph set is intentionally minimal - just what axis labels need:
//! digits, `$`, `.`, `,`, `-`, and the `T`/`B`/`M` magnitude suffixes
//! emitted by `format.fmtLargeNum`, plus space. Unknown chars render blank.
//! The glyph set is intentionally minimal - just what axis labels and
//! chart legends need: digits, `$`, `.`, `,`, `-`, the `T`/`B`/`M`
//! magnitude suffixes emitted by `format.fmtLargeNum`, and the
//! lowercase letters `t`/`h`/`e`/`n`/`o`/`w` for the comparison
//! chart's "then"/"now" legend. Space and unknown chars render blank.
//!
//! Coordinates are in surface pixels; `scale` multiplies the 5x7 cell
//! (so `scale = 3` renders 15x21 glyphs). Drawing is clipped to the
@ -59,6 +61,16 @@ const glyph_T: Glyph = .{ 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 };
const glyph_B: Glyph = .{ 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E };
const glyph_M: Glyph = .{ 0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11 };
// Lowercase letters - just what the comparison chart's "then"/"now"
// legend needs (t, h, e, n, o, w). 5x7, low 5 bits per row. Named
// `lc_*` to avoid colliding with the `glyph_w`/`glyph_h` cell dims.
const lc_t: Glyph = .{ 0x08, 0x08, 0x1C, 0x08, 0x08, 0x08, 0x0C };
const lc_h: Glyph = .{ 0x10, 0x10, 0x10, 0x1E, 0x12, 0x12, 0x12 };
const lc_e: Glyph = .{ 0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E };
const lc_n: Glyph = .{ 0x00, 0x00, 0x1E, 0x12, 0x12, 0x12, 0x12 };
const lc_o: Glyph = .{ 0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E };
const lc_w: Glyph = .{ 0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A };
/// Look up the bitmap for a character. Unknown characters (including
/// space) render blank.
fn glyphFor(ch: u8) Glyph {
@ -71,6 +83,12 @@ fn glyphFor(ch: u8) Glyph {
'T' => glyph_T,
'B' => glyph_B,
'M' => glyph_M,
't' => lc_t,
'h' => lc_h,
'e' => lc_e,
'n' => lc_n,
'o' => lc_o,
'w' => lc_w,
else => blank,
};
}
@ -209,3 +227,13 @@ test "drawText renders the comma glyph (so thousands separators show)" {
// The comma bitmap (rows 0x06,0x06,0x08) has 5 set pixels.
try testing.expectEqual(@as(usize, 5), draw.countColor(&sfc, white));
}
test "drawText renders the lowercase legend letters (then/now)" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 16);
defer sfc.deinit(alloc);
const white = [3]u8{ 0xFF, 0xFF, 0xFF };
// "thenow" exercises all six lowercase glyphs; none may be blank.
drawText(&sfc, 1, 1, 1, white, "thenow");
try testing.expect(draw.countColor(&sfc, white) > 30);
}

View file

@ -25,6 +25,7 @@ const chart_export = @import("../chart_export.zig");
const projection_chart = @import("../charts/projection_chart.zig");
const projections = @import("../analytics/projections.zig");
const forecast_chart = @import("../charts/forecast_chart.zig");
const compare_chart = @import("../charts/compare_chart.zig");
const braille = @import("../charts/braille.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
@ -68,6 +69,9 @@ pub const CompareArgs = struct {
/// "Now" side. Null = today (live); non-null = the `--as-of`
/// date the user paired with `--vs`.
as_of: ?Date = null,
/// When set, render the side-by-side comparison overlay as a PNG
/// to this path and exit. No text output.
export_chart: ?[]const u8 = null,
};
pub const meta: framework.Meta = .{
@ -110,10 +114,10 @@ pub const meta: framework.Meta = .{
\\ CPI-adjusted dollars.
\\ --export-chart <PATH> Render the current mode's chart as a
\\ PNG to PATH (1920x1080) and exit.
\\ Works in the default bands mode (with
\\ the overlay if --overlay-actuals is
\\ set), --convergence, and
\\ --return-backtest. Not valid with --vs.
\\ Works in all modes: the default bands
\\ view (with the overlay if
\\ --overlay-actuals is set), --convergence,
\\ --return-backtest, and --vs.
\\
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
\\
@ -216,14 +220,6 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
cli.stderrPrint(io, "Error: --overlay-actuals requires --as-of.\n");
return error.MutuallyExclusive;
}
// --vs is text-only (a then/now delta table) with no chart to
// export. The bands, convergence, and return-backtest modes all
// support --export-chart.
if (export_chart != null and vs_date != null) {
cli.stderrPrint(io, "Error: --export-chart is not supported with --vs (it has no chart).\n");
return error.MutuallyExclusive;
}
if (convergence) return ParsedArgs{ .convergence = .{ .export_chart = export_chart } };
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode, .export_chart = export_chart } };
if (vs_date) |d| {
@ -231,6 +227,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
.events_enabled = events_enabled,
.vs_date = d,
.as_of = as_of,
.export_chart = export_chart,
} };
}
return ParsedArgs{ .bands = .{
@ -294,6 +291,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.today = today,
.live = if (live) |*l| l else null,
},
kitty_caps,
args.export_chart,
);
},
.bands => |args| {
@ -1062,6 +1061,15 @@ fn extractKeyMetrics(ctx: view.ProjectionContext) KeyMetrics {
};
}
/// The longest-horizon percentile band envelope for a context, or
/// null when no horizon/bands are available. Used to retain the
/// `--vs` comparison overlay's two envelopes.
fn longestBands(ctx: view.ProjectionContext) ?[]const projections.YearPercentiles {
const horizons = ctx.config.getHorizons();
if (horizons.len == 0) return null;
return ctx.data.bands[horizons.len - 1];
}
/// Build a `ProjectionContext` for the `--vs` / `compare --projections`
/// "then" or snapshot "now" side at `requested_date`.
///
@ -1121,6 +1129,8 @@ pub fn runCompare(
ctx: *framework.RunCtx,
file_path: []const u8,
opts: KeyComparisonOptions,
kitty_caps: ?term_query.Caps,
export_chart: ?[]const u8,
) !void {
const io = ctx.io;
const allocator = ctx.allocator;
@ -1138,6 +1148,26 @@ pub fn runCompare(
};
defer result.cleanup();
// --export-chart: render the comparison overlay to a PNG and exit
// before any text output.
if (export_chart) |export_path| {
if (result.then_bands == null or result.now_bands == null) {
cli.stderrPrint(io, "Error: projection bands unavailable for one side; cannot export comparison chart.\n");
return;
}
chart_export.exportCompareChart(io, allocator, result.then_bands.?, result.now_bands.?, export_path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(io, "Error: not enough projection data to render a comparison chart.\n");
return;
},
else => {
cli.stderrPrint(io, "Error: failed to write PNG.\n");
return err;
},
};
return;
}
try out.print("\n", .{});
var then_buf: [10]u8 = undefined;
var now_buf: [10]u8 = undefined;
@ -1178,6 +1208,21 @@ pub fn runCompare(
}
try out.print("\n", .{});
// Inline comparison overlay above the table when supported. No
// braille fallback - non-kitty terminals get the table only.
if (kitty_caps) |kc| {
if (result.then_bands != null and result.now_bands != null) {
const d = projectionChartDims(kc);
if (compare_chart.renderCompareChart(io, va, result.then_bands.?, result.now_bands.?, d.width, d.height, theme.default_theme)) |cres| {
try term_graphics.placeInline(out, va, cres.rgb_data, d.width, d.height, d.cols, d.rows);
try out.print("\n", .{});
} else |err| switch (err) {
error.InsufficientData => {},
else => return err,
}
}
}
try renderKeyComparisonRows(out, color, result.then, result.now, result.events_enabled);
try cli.printFg(out, color, cli.CLR_MUTED, "\nFor the full benchmark + SWR tables run `zfin projections --as-of {s}` and `zfin projections{s}`.\n", .{
@ -1372,6 +1417,11 @@ fn renderForecastLines(
pub const KeyComparisonResult = struct {
then: KeyMetrics,
now: KeyMetrics,
/// Longest-horizon percentile bands for each side, retained for
/// the `--vs` comparison overlay chart. Arena-lived (the caller's
/// `va`); null when a side produced no bands. Both aligned at year 0.
then_bands: ?[]const projections.YearPercentiles = null,
now_bands: ?[]const projections.YearPercentiles = null,
/// Resolution of the "then" snapshot. Always present.
resolution: AsOfResolution,
/// Resolution of the "now" snapshot. Null when now is live.
@ -1507,6 +1557,8 @@ pub fn computeKeyComparison(
return .{
.then = extractKeyMetrics(then_ctx),
.now = extractKeyMetrics(now_ctx),
.then_bands = longestBands(then_ctx),
.now_bands = longestBands(now_ctx),
.resolution = then_resolution,
.now_resolution = now_resolution,
.events_enabled = opts.events_enabled,
@ -1540,6 +1592,8 @@ pub fn computeKeyComparison(
return .{
.then = extractKeyMetrics(then_ctx),
.now = extractKeyMetrics(now_ctx),
.then_bands = longestBands(then_ctx),
.now_bands = longestBands(now_ctx),
.resolution = then_resolution,
.now_resolution = null,
.events_enabled = opts.events_enabled,
@ -1954,6 +2008,16 @@ test "projectionChartDims: standard column width, sane pixel/row footprint" {
try testing.expect(d.width > 0 and d.height > 0);
}
test "parseArgs: --vs with --export-chart carries into compare" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--vs", "2024-01-01", "--export-chart", "out.png" };
const parsed = try parseArgsForTest(today, &args);
switch (parsed) {
.compare => |c| try testing.expect(c.export_chart != null),
else => try testing.expect(false),
}
}
const snapshot_model = @import("../models/snapshot.zig");
const snapshot = @import("snapshot.zig");