zoom overlay the same way it gets zoomed in the TUI (default zoom only)

This commit is contained in:
Emil Lerch 2026-06-26 15:45:44 -07:00
parent 98f0f96bf5
commit 42a16cbbd3
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 105 additions and 40 deletions

View file

@ -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");

View file

@ -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;

View file

@ -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),