From a70e61d873fc5a4b3dde5dc296b972d1d6a49697 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 26 Jun 2026 16:23:39 -0700 Subject: [PATCH] projections CLI kitty-chart for return-backtest and convergence (if terminal supported) --- src/commands/projections.zig | 82 +++++++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 4ff06eb..27f846c 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -24,6 +24,7 @@ const shiller = @import("../data/shiller.zig"); 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 braille = @import("../charts/braille.zig"); const term_graphics = @import("../term_graphics.zig"); const term_query = @import("../term_query.zig"); @@ -251,9 +252,19 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer pf.deinit(allocator); const file_path = pf.path; + // Inline kitty charts when the terminal supports it (or `--chart + // kitty` forces it). No braille fallback for the projection-family + // charts - non-kitty terminals get table-only output. Shared by the + // bands, convergence, and return-backtest modes. + const kitty_caps: ?term_query.Caps = switch (ctx.globals.chart_config.mode) { + .braille => null, + .kitty => ctx.graphics_caps, + .auto => if (ctx.graphics_caps.kitty) ctx.graphics_caps else null, + }; + switch (parsed) { - .convergence => |args| try runConvergence(io, allocator, file_path, args.export_chart, color, out), - .return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, args.export_chart, color, out), + .convergence => |args| try runConvergence(io, allocator, file_path, args.export_chart, color, out, kitty_caps), + .return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, args.export_chart, color, out, kitty_caps), .compare => |args| { _ = ctx.svc orelse return error.MissingDataService; // Pre-load today's live composition only when it's @@ -295,14 +306,6 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // total. Snapshot-only as-of paths ignore it. var live = try loadLiveData(ctx, today, color); defer if (live) |*l| l.deinit(allocator); - // Inline kitty band chart when supported (or forced). There's - // no braille fallback for projections - non-kitty terminals - // keep the table-only output. - const kitty_caps: ?term_query.Caps = switch (ctx.globals.chart_config.mode) { - .braille => null, - .kitty => ctx.graphics_caps, - .auto => if (ctx.graphics_caps.kitty) ctx.graphics_caps else null, - }; try runBands( io, allocator, @@ -597,6 +600,19 @@ fn prepOverlayChart( }; } +/// Pixel + cell dimensions for an inline projection-family chart at +/// the standard column width, derived from the terminal's cell size. +/// Shared by the bands, convergence, and return-backtest inline-kitty +/// paths so they all render at the same on-screen footprint. +const ProjChartDims = struct { width: u32, height: u32, cols: u16, rows: u16 }; + +fn projectionChartDims(caps: term_query.Caps) ProjChartDims { + const cols = term_graphics.projection_cols; + const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h); + const dims = term_graphics.pixelDims(cols, rows, caps.cell_w, caps.cell_h); + return .{ .width = dims.width, .height = dims.height, .cols = cols, .rows = rows }; +} + /// Render the percentile-band chart (longest horizon, with the actuals /// overlay when present) as kitty graphics at `term_graphics.projection_cols` /// wide and emit it inline. Returns `error.InsufficientData` when bands @@ -617,14 +633,11 @@ fn emitBandsKitty( // overlay window) the same way the PNG-export path does. const oc = prepOverlayChart(va, ctx, bands_ec); - const cols = term_graphics.projection_cols; - const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h); - const dims = term_graphics.pixelDims(cols, rows, caps.cell_w, caps.cell_h); - - var rendered = try projection_chart.renderToSurface(io, va, oc.bands, dims.width, dims.height, theme.default_theme, oc.overlay, true, ctx.retirement.boundaryYear()); + const d = projectionChartDims(caps); + var rendered = try projection_chart.renderToSurface(io, va, oc.bands, d.width, d.height, theme.default_theme, oc.overlay, true, ctx.retirement.boundaryYear()); defer rendered.deinit(va); const rgb = try rendered.extractRgb(va); - try term_graphics.placeInline(out, va, rgb, dims.width, dims.height, cols, rows); + try term_graphics.placeInline(out, va, rgb, d.width, d.height, d.cols, d.rows); } pub fn runBands( @@ -1202,6 +1215,7 @@ pub fn runConvergence( export_chart: ?[]const u8, color: bool, out: *std.Io.Writer, + kitty_caps: ?term_query.Caps, ) !void { var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); @@ -1234,6 +1248,20 @@ pub fn runConvergence( return; } + // Inline kitty chart above the table when supported. No braille + // fallback - non-kitty terminals get the table only. Too few points + // skips the chart and still renders the table below. + if (kitty_caps) |kc| { + const d = projectionChartDims(kc); + if (forecast_chart.renderConvergenceChart(io, va, points, d.width, d.height, theme.default_theme)) |result| { + try out.print("\n", .{}); + try term_graphics.placeInline(out, va, result.rgb_data, d.width, d.height, d.cols, d.rows); + } else |err| switch (err) { + error.InsufficientData => {}, + else => return err, + } + } + const lines = try view.convergenceLines(va, points); try renderForecastLines(out, color, lines); } @@ -1258,6 +1286,7 @@ pub fn runReturnBacktest( export_chart: ?[]const u8, color: bool, out: *std.Io.Writer, + kitty_caps: ?term_query.Caps, ) !void { var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); @@ -1298,6 +1327,19 @@ pub fn runReturnBacktest( return; } + // Inline kitty chart above the table when supported (see + // runConvergence). Too few anchors skips the chart; table still renders. + if (kitty_caps) |kc| { + const d = projectionChartDims(kc); + if (forecast_chart.renderBacktestChart(io, va, anchors, d.width, d.height, theme.default_theme)) |result| { + try out.print("\n", .{}); + try term_graphics.placeInline(out, va, result.rgb_data, d.width, d.height, d.cols, d.rows); + } else |err| switch (err) { + error.InsufficientData => {}, + else => return err, + } + } + const lines = try view.backtestLines(va, anchors, real_mode); try renderForecastLines(out, color, lines); } @@ -1904,6 +1946,14 @@ test "parseArgs: --overlay-actuals without --as-of is rejected" { try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args)); } +test "projectionChartDims: standard column width, sane pixel/row footprint" { + const caps = term_query.Caps{ .kitty = true, .cell_w = 10, .cell_h = 20 }; + const d = projectionChartDims(caps); + try testing.expectEqual(term_graphics.projection_cols, d.cols); + try testing.expect(d.rows > 0); + try testing.expect(d.width > 0 and d.height > 0); +} + const snapshot_model = @import("../models/snapshot.zig"); const snapshot = @import("snapshot.zig");