draw vertical line on projections chart for projected retirement date
This commit is contained in:
parent
acf5f723f8
commit
6827b70f85
6 changed files with 113 additions and 24 deletions
7
TODO.md
7
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)`
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue