zoom overlay the same way it gets zoomed in the TUI (default zoom only)
This commit is contained in:
parent
98f0f96bf5
commit
42a16cbbd3
3 changed files with 105 additions and 40 deletions
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue