diff --git a/src/chart_export.zig b/src/chart_export.zig index a2e9de8..59fce76 100644 --- a/src/chart_export.zig +++ b/src/chart_export.zig @@ -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), + ); +} diff --git a/src/charts/compare_chart.zig b/src/charts/compare_chart.zig new file mode 100644 index 0000000..3132fb5 --- /dev/null +++ b/src/charts/compare_chart.zig @@ -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), + ); +} diff --git a/src/charts/text.zig b/src/charts/text.zig index 74e8608..f51ecc5 100644 --- a/src/charts/text.zig +++ b/src/charts/text.zig @@ -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); +} diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 27f846c..f818b38 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -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 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");