diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 3f5eb5b..4ff06eb 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -23,6 +23,7 @@ const milestones = @import("../analytics/milestones.zig"); 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 braille = @import("../charts/braille.zig"); const term_graphics = @import("../term_graphics.zig"); const term_query = @import("../term_query.zig"); @@ -205,6 +206,15 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n"); return error.MutuallyExclusive; } + // The actuals overlay plots the realized trajectory from `--as-of` + // through today; without an as-of anchor there's nothing to plot. + // Matches the TUI, which refuses the overlay without an as-of date. + // (Under `--vs` the overlay is ignored rather than required, so + // that combination is left alone.) + if (overlay_actuals and as_of == null and vs_date == null) { + 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. @@ -556,6 +566,37 @@ pub fn anyImportedOnly( return now_res.source == .imported; } +/// The chart-ready overlay input + band slice for the bands-mode +/// chart. Shared by the inline-kitty (`emitBandsKitty`) and PNG-export +/// paths so both translate the actuals overlay and frame it (zoom to +/// the overlay window) identically. +const OverlayChart = struct { + overlay: ?projection_chart.ActualsOverlay, + bands: []const projections.YearPercentiles, +}; + +/// Translate the context's actuals overlay into the chart module's +/// shape and pick the band slice to render: zoomed to the overlay +/// window (`view.overlayZoomBands`) when an overlay is present, else +/// the full `bands_ec`. Overlay points are allocated from the arena +/// `va`, so no explicit free is needed. +fn prepOverlayChart( + va: std.mem.Allocator, + ctx: *const view.ProjectionContext, + bands_ec: []const projections.YearPercentiles, +) OverlayChart { + const overlay: ?projection_chart.ActualsOverlay = blk: { + const ov = ctx.overlay_actuals orelse break :blk null; + const buf = va.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk null; + for (ov.points, 0..) |p, i| buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid }; + break :blk .{ .points = buf, .today_years = ov.today_years }; + }; + return .{ + .overlay = overlay, + .bands = view.overlayZoomBands(bands_ec, if (overlay) |ov| ov.today_years else null), + }; +} + /// 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 @@ -572,21 +613,15 @@ fn emitBandsKitty( if (horizons.len == 0) return error.InsufficientData; const bands_ec = ctx.data.bands[horizons.len - 1] orelse return error.InsufficientData; - // Translate the view-layer overlay (if any) into the chart module's - // ActualsPoint shape - same conversion as the PNG export path. The - // arena owns the buffer; it lives as long as `overlay_input`. - const overlay_input = blk: { - const ov = ctx.overlay_actuals orelse break :blk @as(?projection_chart.ActualsOverlay, null); - const buf = va.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk @as(?projection_chart.ActualsOverlay, null); - for (ov.points, 0..) |p, i| buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid }; - break :blk projection_chart.ActualsOverlay{ .points = buf, .today_years = ov.today_years }; - }; + // Translate the overlay and frame the band slice (zoomed to the + // 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, bands_ec, dims.width, dims.height, theme.default_theme, overlay_input, true, ctx.retirement.boundaryYear()); + var rendered = try projection_chart.renderToSurface(io, va, oc.bands, dims.width, dims.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); @@ -714,25 +749,11 @@ pub fn runBands( return; }; - // Translate the view-layer overlay points (if any) into the - // chart-module's ActualsPoint shape. Same conversion the TUI - // does in `projections_tab.drawWithKittyChart`. - var overlay_buf: ?[]@import("../charts/projection_chart.zig").ActualsPoint = null; - defer if (overlay_buf) |ob| va.free(ob); - const overlay_input = blk: { - const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../charts/projection_chart.zig").ActualsOverlay, null); - const buf = va.alloc(@import("../charts/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../charts/projection_chart.zig").ActualsOverlay, null); - for (ov.points, 0..) |p, i| { - buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid }; - } - overlay_buf = buf; - break :blk @import("../charts/projection_chart.zig").ActualsOverlay{ - .points = buf, - .today_years = ov.today_years, - }; - }; + // Translate the overlay and frame the band slice (zoomed to the + // overlay window) the same way the inline-kitty path does. + const oc = prepOverlayChart(va, &ctx, bands_ec); - chart_export.exportProjectionChart(io, allocator, bands_ec, overlay_input, ctx.retirement.boundaryYear(), export_path) catch |err| switch (err) { + chart_export.exportProjectionChart(io, allocator, oc.bands, oc.overlay, ctx.retirement.boundaryYear(), export_path) catch |err| switch (err) { error.InsufficientData => { cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n"); return; @@ -1877,6 +1898,12 @@ test "parseArgs: --overlay-actuals carries into bands" { } } +test "parseArgs: --overlay-actuals without --as-of is rejected" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{"--overlay-actuals"}; + try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args)); +} + const snapshot_model = @import("../models/snapshot.zig"); const snapshot = @import("snapshot.zig"); diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index ba47738..5da2887 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -863,16 +863,10 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [ const ov = ctx_data.overlay_actuals orelse break :blk null; break :blk ov.today_years; }; - const bands = if (state.zoom_overlay) bz: { - const ty = overlay_today_years orelse break :bz full_bands; - const window_years_f = ty * 2.0; - if (window_years_f <= 0 or !std.math.isFinite(window_years_f)) break :bz full_bands; - const window_years: usize = @intFromFloat(@ceil(window_years_f)); - const want = window_years + 1; // inclusive of year 0 and year `window_years` - if (want >= full_bands.len) break :bz full_bands; - if (want < 2) break :bz full_bands; - break :bz full_bands[0..want]; - } else full_bands; + const bands = if (state.zoom_overlay) + view.overlayZoomBands(full_bands, overlay_today_years) + else + full_bands; // Build text header (benchmark comparison + allocation note) var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty; @@ -1094,9 +1088,11 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [ }; } } - // "{horizon}yr" at right edge of chart area + // "{years}yr" at right edge of chart area - the last + // rendered band's year, so a zoomed overlay window + // labels its true span (not the full horizon). var yr_buf: [8]u8 = undefined; - const yr_label = std.fmt.bufPrint(&yr_buf, "{d}yr", .{horizons[last_idx]}) catch "??yr"; + const yr_label = std.fmt.bufPrint(&yr_buf, "{d}yr", .{bands[bands.len - 1].year}) catch "??yr"; const yr_start = chart_col_start + @as(usize, chart_cols) -| yr_label.len; for (yr_label, 0..) |ch, ci| { const idx = axis_base + yr_start + ci; diff --git a/src/views/projections.zig b/src/views/projections.zig index 19d70e8..8cd943c 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -279,6 +279,28 @@ pub fn buildOverlayActuals( }; } +/// Clamp projection `full_bands` to the overlay-relevant window so a +/// short realized history isn't squashed into the start of a long +/// horizon. Returns the leading slice covering roughly +/// `[year 0, 2*today_years]` (both endpoints inclusive). Falls back to +/// `full_bands` unchanged when there's no overlay (`today_years` null), +/// the window is degenerate, or it would span the whole horizon. +/// Shared by the TUI zoom toggle and the CLI export/inline paths so an +/// overlay is framed identically in both. +pub fn overlayZoomBands( + full_bands: []const projections.YearPercentiles, + today_years: ?f64, +) []const projections.YearPercentiles { + const ty = today_years orelse return full_bands; + const window_years_f = ty * 2.0; + if (window_years_f <= 0 or !std.math.isFinite(window_years_f)) return full_bands; + const window_years: usize = @intFromFloat(@ceil(window_years_f)); + const want = window_years + 1; // inclusive of year 0 and year `window_years` + if (want >= full_bands.len) return full_bands; + if (want < 2) return full_bands; + return full_bands[0..want]; +} + /// Which retirement-planning inputs the user has configured. /// /// The simulation always runs the same two-phase model @@ -2132,6 +2154,26 @@ test "buildOverlayActuals: empty input produces empty section" { try std.testing.expectApproxEqAbs(@as(f64, 1.0), section.today_years, 0.01); } +test "overlayZoomBands: clamps to ~2x today_years, falls back when appropriate" { + // 51 bands: year 0..50. + var full: [51]projections.YearPercentiles = undefined; + for (0..51) |i| full[i] = .{ .year = @intCast(i), .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }; + + // No overlay -> full slice unchanged. + try std.testing.expectEqual(@as(usize, 51), overlayZoomBands(&full, null).len); + + // today_years = 2.5 -> window = ceil(5.0) = 5 -> 6 bands (years 0..5). + const z = overlayZoomBands(&full, 2.5); + try std.testing.expectEqual(@as(usize, 6), z.len); + try std.testing.expectEqual(@as(u16, 5), z[z.len - 1].year); + + // Degenerate today_years (<= 0) -> full slice. + try std.testing.expectEqual(@as(usize, 51), overlayZoomBands(&full, 0).len); + + // Window wider than the horizon -> full slice (no OOB). + try std.testing.expectEqual(@as(usize, 51), overlayZoomBands(&full, 100.0).len); +} + test "buildOverlayActuals: single point at as_of has years=0" { const points = [_]timeline.TimelinePoint{ makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),