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 shiller = @import("../data/shiller.zig");
|
||||||
const chart_export = @import("../chart_export.zig");
|
const chart_export = @import("../chart_export.zig");
|
||||||
const projection_chart = @import("../charts/projection_chart.zig");
|
const projection_chart = @import("../charts/projection_chart.zig");
|
||||||
|
const projections = @import("../analytics/projections.zig");
|
||||||
const braille = @import("../charts/braille.zig");
|
const braille = @import("../charts/braille.zig");
|
||||||
const term_graphics = @import("../term_graphics.zig");
|
const term_graphics = @import("../term_graphics.zig");
|
||||||
const term_query = @import("../term_query.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");
|
cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
|
||||||
return error.MutuallyExclusive;
|
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
|
// --vs is text-only (a then/now delta table) with no chart to
|
||||||
// export. The bands, convergence, and return-backtest modes all
|
// export. The bands, convergence, and return-backtest modes all
|
||||||
// support --export-chart.
|
// support --export-chart.
|
||||||
|
|
@ -556,6 +566,37 @@ pub fn anyImportedOnly(
|
||||||
return now_res.source == .imported;
|
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
|
/// Render the percentile-band chart (longest horizon, with the actuals
|
||||||
/// overlay when present) as kitty graphics at `term_graphics.projection_cols`
|
/// overlay when present) as kitty graphics at `term_graphics.projection_cols`
|
||||||
/// wide and emit it inline. Returns `error.InsufficientData` when bands
|
/// wide and emit it inline. Returns `error.InsufficientData` when bands
|
||||||
|
|
@ -572,21 +613,15 @@ fn emitBandsKitty(
|
||||||
if (horizons.len == 0) return error.InsufficientData;
|
if (horizons.len == 0) return error.InsufficientData;
|
||||||
const bands_ec = ctx.data.bands[horizons.len - 1] orelse 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
|
// Translate the overlay and frame the band slice (zoomed to the
|
||||||
// ActualsPoint shape - same conversion as the PNG export path. The
|
// overlay window) the same way the PNG-export path does.
|
||||||
// arena owns the buffer; it lives as long as `overlay_input`.
|
const oc = prepOverlayChart(va, ctx, bands_ec);
|
||||||
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 };
|
|
||||||
};
|
|
||||||
|
|
||||||
const cols = term_graphics.projection_cols;
|
const cols = term_graphics.projection_cols;
|
||||||
const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h);
|
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);
|
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);
|
defer rendered.deinit(va);
|
||||||
const rgb = try rendered.extractRgb(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, dims.width, dims.height, cols, rows);
|
||||||
|
|
@ -714,25 +749,11 @@ pub fn runBands(
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Translate the view-layer overlay points (if any) into the
|
// Translate the overlay and frame the band slice (zoomed to the
|
||||||
// chart-module's ActualsPoint shape. Same conversion the TUI
|
// overlay window) the same way the inline-kitty path does.
|
||||||
// does in `projections_tab.drawWithKittyChart`.
|
const oc = prepOverlayChart(va, &ctx, bands_ec);
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
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 => {
|
error.InsufficientData => {
|
||||||
cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n");
|
cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n");
|
||||||
return;
|
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_model = @import("../models/snapshot.zig");
|
||||||
const snapshot = @import("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;
|
const ov = ctx_data.overlay_actuals orelse break :blk null;
|
||||||
break :blk ov.today_years;
|
break :blk ov.today_years;
|
||||||
};
|
};
|
||||||
const bands = if (state.zoom_overlay) bz: {
|
const bands = if (state.zoom_overlay)
|
||||||
const ty = overlay_today_years orelse break :bz full_bands;
|
view.overlayZoomBands(full_bands, overlay_today_years)
|
||||||
const window_years_f = ty * 2.0;
|
else
|
||||||
if (window_years_f <= 0 or !std.math.isFinite(window_years_f)) break :bz full_bands;
|
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;
|
|
||||||
|
|
||||||
// Build text header (benchmark comparison + allocation note)
|
// Build text header (benchmark comparison + allocation note)
|
||||||
var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
|
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;
|
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;
|
const yr_start = chart_col_start + @as(usize, chart_cols) -| yr_label.len;
|
||||||
for (yr_label, 0..) |ch, ci| {
|
for (yr_label, 0..) |ch, ci| {
|
||||||
const idx = axis_base + yr_start + 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.
|
/// Which retirement-planning inputs the user has configured.
|
||||||
///
|
///
|
||||||
/// The simulation always runs the same two-phase model
|
/// 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);
|
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" {
|
test "buildOverlayActuals: single point at as_of has years=0" {
|
||||||
const points = [_]timeline.TimelinePoint{
|
const points = [_]timeline.TimelinePoint{
|
||||||
makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),
|
makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue