From 6827b70f85f2f516bd020411dcf2e5f790e7aa60 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 26 Jun 2026 12:37:30 -0700 Subject: [PATCH] draw vertical line on projections chart for projected retirement date --- TODO.md | 7 --- src/analytics/projections.zig | 24 +++++++++ src/chart_export.zig | 10 ++-- src/charts/projection_chart.zig | 91 ++++++++++++++++++++++++++++----- src/commands/projections.zig | 4 +- src/tui/projections_tab.zig | 1 + 6 files changed, 113 insertions(+), 24 deletions(-) diff --git a/TODO.md b/TODO.md index caa6aa0..c3aa2a5 100644 --- a/TODO.md +++ b/TODO.md @@ -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)` diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index 6adfadc..4bfd493 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -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" { diff --git a/src/chart_export.zig b/src/chart_export.zig index 7011334..2701cbb 100644 --- a/src/chart_export.zig +++ b/src/chart_export.zig @@ -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), ); } diff --git a/src/charts/projection_chart.zig b/src/charts/projection_chart.zig index 2bf145b..15b71f6 100644 --- a/src/charts/projection_chart.zig +++ b/src/charts/projection_chart.zig @@ -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)); +} diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 680bacb..934099b 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -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; diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index f6237b1..ba47738 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -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;