draw vertical line on projections chart for projected retirement date

This commit is contained in:
Emil Lerch 2026-06-26 12:37:30 -07:00
parent acf5f723f8
commit 6827b70f85
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 113 additions and 24 deletions

View file

@ -7,13 +7,6 @@ ranking; unlabeled items are "someday, if the mood strikes."
## Projections: future enhancements
- **Chart vertical line at retirement boundary - priority LOW.**
The accumulation-phase spec called this "mandatory" but it was
explicitly deferred during implementation. The chart currently
shows the full `accumulation_years + horizon` span without a
visual marker for where accumulation ends and distribution
begins. Easier to add to the kitty-graphics chart than the braille
one.
- **Goal-seek over distribution horizon for W1 - priority LOW.**
Today the W1 ("set spending, find date") workflow reports the
earliest retirement at each user-configured `(horizon, confidence)`

View file

@ -142,6 +142,18 @@ pub const ResolvedRetirement = struct {
/// The line renders "not feasible" instead of a date.
promoted_infeasible,
},
/// The accumulation/distribution boundary as a year-offset for the
/// projection chart's x-axis. `bands[i].year == i` in production,
/// so this offset doubles as the band index. Returns `null` when
/// there's no accumulation phase to mark (`accumulation_years == 0`:
/// already retired or distribution-only), in which case the chart
/// draws no divider. Otherwise the chart draws a vertical line at
/// this offset separating the saving phase (left) from the
/// withdrawal phase (right).
pub fn boundaryYear(self: ResolvedRetirement) ?u16 {
return if (self.accumulation_years == 0) null else self.accumulation_years;
}
};
/// User-configurable projection parameters, loaded from projections.srf.
@ -2594,6 +2606,18 @@ test "resolveRetirement: retirement_age and retirement_at agree on same boundary
try std.testing.expect(r1.date.?.eql(r2.date.?));
}
test "ResolvedRetirement.boundaryYear: zero accumulation -> null, positive -> offset" {
// No accumulation phase (already retired / distribution-only):
// no divider to draw.
const none_r: ResolvedRetirement = .{ .accumulation_years = 0, .date = null, .source = .none };
try std.testing.expectEqual(@as(?u16, null), none_r.boundaryYear());
// An accumulation phase: the boundary offset equals
// accumulation_years (which doubles as the band index).
const acc_r: ResolvedRetirement = .{ .accumulation_years = 12, .date = Date.fromYmd(2038, 1, 1), .source = .at_age };
try std.testing.expectEqual(@as(?u16, 12), acc_r.boundaryYear());
}
// Two-phase simulation regression tests
test "regression: findSafeWithdrawal(30, 1M, 0.75, 0.95) unchanged" {

View file

@ -78,12 +78,15 @@ pub fn exportSymbolChart(
/// Export a projection percentile-band chart with optional actuals
/// overlay. Wraps `projection_chart.renderToSurface` +
/// `writeToPNGFile`.
/// `writeToPNGFile`. `retirement_boundary_year` (pass
/// `ResolvedRetirement.boundaryYear()`) draws the
/// accumulation/distribution divider when set.
pub fn exportProjectionChart(
io: std.Io,
alloc: std.mem.Allocator,
bands: []const projections.YearPercentiles,
actuals: ?projection_chart.ActualsOverlay,
retirement_boundary_year: ?u16,
path: []const u8,
) !void {
var rendered = projection_chart.renderToSurface(
@ -95,6 +98,7 @@ pub fn exportProjectionChart(
theme.default_theme,
actuals,
true,
retirement_boundary_year,
) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
else => return err,
@ -235,7 +239,7 @@ test "exportProjectionChart writes a non-empty PNG file" {
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_projection.png" });
defer alloc.free(path);
try exportProjectionChart(io, alloc, &bands, null, path);
try exportProjectionChart(io, alloc, &bands, null, null, path);
var file = try tmp.dir.openFile(io, "test_export_projection.png", .{});
defer file.close(io);
@ -271,7 +275,7 @@ test "exportProjectionChart returns InsufficientData with single band" {
try std.testing.expectError(
error.InsufficientData,
exportProjectionChart(io, alloc, &bands, null, path),
exportProjectionChart(io, alloc, &bands, null, null, path),
);
}

View file

@ -12,6 +12,7 @@
//! - Median (p50) line (solid)
//! - Zero line (if visible)
//! - Actuals overlay line (when present)
//! - Retirement boundary vertical line (when an accumulation phase exists)
//! - Panel border
const std = @import("std");
@ -90,6 +91,12 @@ pub const RenderedProjection = struct {
/// graphics path (extracts RGB, frees surface).
/// - `--export-chart` (CLI) wraps this for PNG export via
/// `z2d.png_exporter.writeToPNGFile`.
///
/// `retirement_boundary_year`, when non-null and within the band
/// range, draws a vertical divider at that year-offset marking where
/// the accumulation phase ends and distribution begins. Pass
/// `ResolvedRetirement.boundaryYear()`; out-of-range values (0 or
/// past the last band) are ignored.
pub fn renderToSurface(
io: std.Io,
alloc: std.mem.Allocator,
@ -99,6 +106,7 @@ pub fn renderToSurface(
th: theme.Theme,
actuals: ?ActualsOverlay,
axis_labels: bool,
retirement_boundary_year: ?u16,
) !RenderedProjection {
if (bands.len < 2) return error.InsufficientData;
@ -303,6 +311,24 @@ pub fn renderToSurface(
}
}
// Retirement boundary vertical line
//
// Drawn on top of the bands (not behind, like the quiet "today"
// line) because the accumulation/distribution boundary is a
// structural feature of the projection, not just a time cursor.
// `bands[i].year == i`, so the boundary year-offset is also its
// band index; map it to x exactly as the band points are placed.
// Skipped when there's no accumulation phase (null / 0) or the
// boundary falls outside the (possibly zoom-truncated) window.
if (retirement_boundary_year) |boundary| {
if (boundary > 0 and @as(usize, boundary) <= bands.len - 1) {
const horizon_years: f64 = @floatFromInt(bands.len - 1);
const boundary_x = chart_left + (@as(f64, @floatFromInt(boundary)) / horizon_years) * chart_w;
const boundary_color = blendColor(th.warning, 200, bg);
try drawVLine(&ctx, boundary_x, chart_top, chart_bottom, boundary_color, 1.5);
}
}
// Panel border
{
const border_color = blendColor(th.border, 80, bg);
@ -343,8 +369,9 @@ pub fn renderProjectionChart(
height_px: u32,
th: theme.Theme,
actuals: ?ActualsOverlay,
retirement_boundary_year: ?u16,
) !ProjectionChartResult {
var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals, false);
var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals, false, retirement_boundary_year);
defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc);
return .{
@ -381,7 +408,7 @@ test "renderProjectionChart produces valid output" {
};
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null, null);
defer alloc.free(result.rgb_data);
try std.testing.expectEqual(@as(u16, 200), result.width);
@ -397,7 +424,7 @@ test "renderProjectionChart insufficient data" {
};
const th = @import("../tui/theme.zig").default_theme;
const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null, null);
try std.testing.expectError(error.InsufficientData, result);
}
@ -416,7 +443,7 @@ test "renderProjectionChart with overlay produces valid output" {
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay, null);
defer alloc.free(result.rgb_data);
try std.testing.expectEqual(@as(u16, 200), result.width);
@ -439,7 +466,7 @@ test "renderProjectionChart overlay expands y-range when actuals exceed bands" {
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay, null);
defer alloc.free(result.rgb_data);
// Without expansion, value_max would be ~25M (band p90 + 5%).
@ -456,7 +483,7 @@ test "renderProjectionChart overlay with no points renders without crash" {
const overlay: ActualsOverlay = .{ .points = &.{}, .today_years = 0.5 };
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay, null);
defer alloc.free(result.rgb_data);
try std.testing.expect(result.rgb_data.len > 0);
}
@ -474,7 +501,7 @@ test "renderToSurface returns a populated RGB surface at requested dimensions" {
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
};
const th = @import("../tui/theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null, false);
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null, false, null);
defer rendered.deinit(alloc);
try std.testing.expectEqual(@as(u16, 150), rendered.width);
@ -494,7 +521,7 @@ test "renderToSurface fills background with theme bg" {
var th = @import("../tui/theme.zig").default_theme;
th.bg = .{ 0xab, 0xcd, 0xef };
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null, false);
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null, false, null);
defer rendered.deinit(alloc);
const buf = switch (rendered.surface) {
@ -515,9 +542,9 @@ test "renderToSurface is deterministic across calls with same input" {
};
const th = @import("../tui/theme.zig").default_theme;
var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false, null);
defer a.deinit(alloc);
var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false, null);
defer b.deinit(alloc);
const buf_a = switch (a.surface) {
@ -544,7 +571,7 @@ test "RenderedProjection.extractRgb produces 3 bytes per pixel" {
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
};
const th = @import("../tui/theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null, false);
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null, false, null);
defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc);
@ -569,7 +596,7 @@ test "renderToSurface clamps value_min to zero when bands include negatives" {
.{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 },
};
const th = @import("../tui/theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false, null);
defer rendered.deinit(alloc);
// After 5% padding and the `if (value_min < 0) value_min = 0`
@ -578,3 +605,43 @@ test "renderToSurface clamps value_min to zero when bands include negatives" {
try std.testing.expectEqual(@as(f64, 0), rendered.value_min);
try std.testing.expect(rendered.value_max > 0);
}
test "renderToSurface draws the retirement-boundary divider only when in range" {
const alloc = std.testing.allocator;
// 11 bands (year 0..10); a boundary at year 5 lands mid-chart.
var bands: [11]projections.YearPercentiles = undefined;
for (0..11) |i| {
const base: f64 = 1_000_000.0 * (1.0 + 0.05 * @as(f64, @floatFromInt(i)));
bands[i] = .{
.year = @intCast(i),
.p10 = base * 0.7,
.p25 = base * 0.85,
.p50 = base,
.p75 = base * 1.15,
.p90 = base * 1.3,
};
}
const th = @import("../tui/theme.zig").default_theme;
const line_px = blendColor(th.warning, 200, th.bg);
const line_rgb = [3]u8{ line_px.rgb.r, line_px.rgb.g, line_px.rgb.b };
// Baseline: no boundary requested.
var none = try renderToSurface(std.testing.io, alloc, &bands, 200, 120, th, null, false, null);
defer none.deinit(alloc);
const base_count = draw.countColor(&none.surface, line_rgb);
// A boundary at year 5 adds divider pixels in the warning color.
var set = try renderToSurface(std.testing.io, alloc, &bands, 200, 120, th, null, false, 5);
defer set.deinit(alloc);
try std.testing.expect(draw.countColor(&set.surface, line_rgb) > base_count);
// Out-of-range offsets draw nothing: 0 (left edge / no phase) and a
// value past the last band index both match the no-boundary baseline.
var zero = try renderToSurface(std.testing.io, alloc, &bands, 200, 120, th, null, false, 0);
defer zero.deinit(alloc);
try std.testing.expectEqual(base_count, draw.countColor(&zero.surface, line_rgb));
var past = try renderToSurface(std.testing.io, alloc, &bands, 200, 120, th, null, false, 99);
defer past.deinit(alloc);
try std.testing.expectEqual(base_count, draw.countColor(&past.surface, line_rgb));
}

View file

@ -585,7 +585,7 @@ fn emitBandsKitty(
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);
var rendered = try projection_chart.renderToSurface(io, va, bands_ec, dims.width, dims.height, theme.default_theme, overlay_input, 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);
@ -731,7 +731,7 @@ pub fn runBands(
};
};
chart_export.exportProjectionChart(io, allocator, bands_ec, overlay_input, export_path) catch |err| switch (err) {
chart_export.exportProjectionChart(io, allocator, bands_ec, overlay_input, 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;

View file

@ -960,6 +960,7 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [
capped_h,
th,
overlay_input,
pctx.retirement.boundaryYear(),
) catch {
state.chart_dirty = false;
return;