get appropriate zoom level when overlaying actuals on the projections chart

This commit is contained in:
Emil Lerch 2026-05-19 13:58:57 -07:00
parent 858cc6fad5
commit 4d1587fb37
3 changed files with 101 additions and 25 deletions

View file

@ -259,7 +259,7 @@ The TUI has eight tabs: Portfolio, Analysis, Projections, History, Quote, Perfor
**Analysis** -- portfolio breakdown by asset class, sector, geographic region, account, and tax type. Uses classification data from `metadata.srf` and account tax types from `accounts.srf`. Displays horizontal bar charts with sub-character precision using Unicode block elements. **Analysis** -- portfolio breakdown by asset class, sector, geographic region, account, and tax type. Uses classification data from `metadata.srf` and account tax types from `accounts.srf`. Displays horizontal bar charts with sub-character precision using Unicode block elements.
**Projections** -- Monte Carlo retirement projection with percentile bands. Press `d` to set an as-of date (back-date the projection to a historical snapshot), `o` to overlay realized actuals from snapshots / `imported_values.srf` on top of the bands, `v` to toggle the chart vs the text-only report, and `e` to toggle simulated lifecycle events (RMDs, lump-sum withdrawals). Esc clears an active as-of override. **Projections** -- Monte Carlo retirement projection with percentile bands. Press `d` to set an as-of date (back-date the projection to a historical snapshot), `o` to overlay realized actuals from snapshots / `imported_values.srf` on top of the bands, `z` to toggle auto-zoom on the overlay (chart x-axis defaults to roughly `[as_of, today + actuals_span]` so a short actuals line isn't squashed into the start of a 50-year horizon), `v` to toggle the chart vs the text-only report, and `e` to toggle simulated lifecycle events (RMDs, lump-sum withdrawals). Esc clears an active as-of override.
**History** -- portfolio value over time, sourced from snapshot files in `<portfolio-dir>/history/` plus optional `imported_values.srf`. Cycle the metric column with `m` (liquid / total / contributions / etc.) and the time-bucket resolution with `t` (week / month / quarter / year). Press `s` (or space) to mark a row for compare; mark a second row, then `c` to commit a side-by-side compare against the live portfolio. Esc cancels an in-flight compare. **History** -- portfolio value over time, sourced from snapshot files in `<portfolio-dir>/history/` plus optional `imported_values.srf`. Cycle the metric column with `m` (liquid / total / contributions / etc.) and the time-bucket resolution with `t` (week / month / quarter / year). Press `s` (or space) to mark a row for compare; mark a second row, then `c` to commit a side-by-side compare against the live portfolio. Esc cancels an in-flight compare.
@ -325,6 +325,7 @@ Default tab-local keybindings (only active on the matching tab):
| Projections | `d` | Set as-of date prompt | | Projections | `d` | Set as-of date prompt |
| Projections | `Esc` | Clear as-of date | | Projections | `Esc` | Clear as-of date |
| Projections | `o` | Toggle realized-actuals overlay | | Projections | `o` | Toggle realized-actuals overlay |
| Projections | `z` | Toggle overlay auto-zoom (clamp x-axis to overlay span) |
| Projections | `v` | Toggle chart vs text-only report | | Projections | `v` | Toggle chart vs text-only report |
| Projections | `e` | Toggle simulated lifecycle events | | Projections | `e` | Toggle simulated lifecycle events |

46
TODO.md
View file

@ -101,28 +101,6 @@ ranking; unlabeled items are "someday, if the mood strikes."
render meaningfully — but each would tighten the historical render meaningfully — but each would tighten the historical
faithfulness one notch. Pick whichever has the highest faithfulness one notch. Pick whichever has the highest
payoff vs. complexity when this gets revisited. payoff vs. complexity when this gets revisited.
- **Chart zoom for short-history overlays.** With a 50-year
projection horizon and only ~10 years of imported actuals,
the actuals line is squashed into the first 20% of the
chart and the comparison-against-bands story is hard to
read. Two design directions:
- **Auto-zoom**: when the overlay is on, the chart's
x-axis defaults to `[as_of, today + N years]` (where
N is small, e.g. 2x the actuals span) instead of
`[as_of, as_of + horizon]`. The bands beyond `today
+ N` are still computed but clipped from view. The
tradeoff: the user loses the long-tail terminal-value
context unless they toggle back out.
- **Toggle**: a separate keybind (e.g. `z` for zoom)
flips between full-horizon and zoomed views. Default
off so the bands tell their full story; user opts in
when they want overlay legibility.
Auto-zoom is more invasive (changes the default chart
semantics for everyone running with overlay-on) but better
matches what the user actually wants when they toggle the
overlay. Toggle is safer but requires the user to know the
feature exists. Probably do auto-zoom but expose a toggle
to escape it ("show full horizon").
## Export chart as PNG (`--export-chart <path>`) — priority MEDIUM ## Export chart as PNG (`--export-chart <path>`) — priority MEDIUM
@ -534,6 +512,30 @@ Once decisions are made, sweep all four sites + add a regression
alignment test per table that mixes a fully-populated row with alignment test per table that mixes a fully-populated row with
an em-dash-heavy row and verifies `displayCols` matches. an em-dash-heavy row and verifies `displayCols` matches.
## TUI: numeric keypad input not handled — priority LOW
Numeric-keypad keys (Num0-Num9, decimal point, minus) don't reach
the modal text-input handlers. Reproduced on the projections tab
as-of date prompt (`d`): typing the date with the numpad produces
no input, while the digit keys on the main keyboard row work fine.
Affects every modal that takes numeric input — symbol-input is
unaffected because it's letters.
Likely cause: the modal handlers route through `vaxis.Key.codepoint`
matching, but vaxis emits keypad keys with a distinct keycode (kitty
keyboard protocol) rather than the codepoint of the equivalent ASCII
digit. The fix is in the modal key paths (`handleDateInputKey` in
`src/tui/projections_tab.zig` and the symbol-input handler in
`src/tui.zig` — though that one is letters-only in practice) and
possibly the shared `input_buffer.zig` if that's where character
gathering lives. Worth surveying both files plus any other tab that
will grow numeric input (the CLI options command's near-the-money
strike count would be a candidate if migrated to a modal).
Verification: open the TUI, press `d` on projections, try to type a
date with the keypad. Then try the keyboard row. Both should commit
identical input.
## CLI dispatch / arg-parsing bugs (found May 2026) ## CLI dispatch / arg-parsing bugs (found May 2026)
Found during a post-framework-refactor sanity check of all 20 Found during a post-framework-refactor sanity check of all 20

View file

@ -81,6 +81,14 @@ pub const Action = enum {
/// `imported_values.srf`. When active, replaces the main bands /// `imported_values.srf`. When active, replaces the main bands
/// chart. /// chart.
toggle_return_backtest, toggle_return_backtest,
/// Toggle auto-zoom on the actuals overlay. When the overlay is
/// active and zoom is on (default), the chart's x-axis is
/// clamped to roughly `[as_of, today + N years]` where N is the
/// actuals span without that clamp, a 10-year actuals line is
/// squashed into the first 20% of a 50-year horizon. Pressing
/// `z` flips back to the full horizon. No-op (with status hint)
/// when the overlay is off.
toggle_overlay_zoom,
}; };
// Tab-private state // Tab-private state
@ -142,6 +150,15 @@ pub const State = struct {
/// meaningful when `as_of` is set; the action flashes a status /// meaningful when `as_of` is set; the action flashes a status
/// message and leaves this off otherwise. /// message and leaves this off otherwise.
overlay_actuals: bool = false, overlay_actuals: bool = false,
/// When true (default), and the actuals overlay is active, the
/// chart x-axis is zoomed to roughly `[as_of, today + N years]`
/// where N is the actuals span. Without this, a 10-year actuals
/// line gets squashed into the first 20% of a 50-year horizon
/// chart and the comparison-against-bands story is hard to read.
/// Toggled by `toggle_overlay_zoom` (`z`); a no-op (with status
/// hint) when the overlay is off because there's nothing
/// short-history to zoom into.
zoom_overlay: bool = true,
/// Active sub-view replacing the main bands chart. The default /// Active sub-view replacing the main bands chart. The default
/// view (`.bands`) renders the standard percentile-band chart /// view (`.bands`) renders the standard percentile-band chart
@ -208,6 +225,7 @@ pub const meta: framework.TabMeta(Action) = .{
.{ .action = .clear_as_of, .key = .{ .codepoint = vaxis.Key.escape } }, .{ .action = .clear_as_of, .key = .{ .codepoint = vaxis.Key.escape } },
.{ .action = .toggle_convergence, .key = .{ .codepoint = 'c' } }, .{ .action = .toggle_convergence, .key = .{ .codepoint = 'c' } },
.{ .action = .toggle_return_backtest, .key = .{ .codepoint = 'b' } }, .{ .action = .toggle_return_backtest, .key = .{ .codepoint = 'b' } },
.{ .action = .toggle_overlay_zoom, .key = .{ .codepoint = 'z' } },
}, },
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{ .action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.overlay_actuals = "Toggle actuals overlay", .overlay_actuals = "Toggle actuals overlay",
@ -217,6 +235,7 @@ pub const meta: framework.TabMeta(Action) = .{
.clear_as_of = "Clear as-of date", .clear_as_of = "Clear as-of date",
.toggle_convergence = "Toggle convergence sub-view", .toggle_convergence = "Toggle convergence sub-view",
.toggle_return_backtest = "Toggle return back-test sub-view", .toggle_return_backtest = "Toggle return back-test sub-view",
.toggle_overlay_zoom = "Toggle overlay zoom",
}), }),
.status_hints = &.{ .status_hints = &.{
.toggle_chart, .toggle_chart,
@ -371,6 +390,23 @@ pub const tab = struct {
state.chart_dirty = true; state.chart_dirty = true;
app.scroll_offset = 0; app.scroll_offset = 0;
}, },
.toggle_overlay_zoom => {
// No-op when the overlay isn't active. Without an
// overlay there's no short-history actuals line to
// give visual priority to, so zoom would just
// truncate the band envelope for no reason.
if (!state.overlay_actuals) {
app.setStatus("Zoom only applies when the actuals overlay is on");
return;
}
state.zoom_overlay = !state.zoom_overlay;
state.chart_dirty = true;
if (state.zoom_overlay) {
app.setStatus("Zoom: ON — x-axis clamped to overlay span");
} else {
app.setStatus("Zoom: OFF — full horizon");
}
},
} }
} }
@ -783,8 +819,35 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [
const config = pctx.config; const config = pctx.config;
const horizons = config.getHorizons(); const horizons = config.getHorizons();
const last_idx = horizons.len - 1; const last_idx = horizons.len - 1;
const bands = pctx.data.bands[last_idx] orelse return; const full_bands = pctx.data.bands[last_idx] orelse return;
if (bands.len < 2) return; if (full_bands.len < 2) return;
// Effective bands slice for both the chart render AND the
// right-edge axis labels. When the overlay is on and zoom is
// enabled, the chart is clamped to roughly `[year 0, year
// 2 * today_years]` so a short actuals history isn't squashed
// into the start of a 50-year horizon. The label code below
// reads `bands[bands.len - 1]` to position p10/p50/p90 etc.
// against the rendered y-range those two views MUST agree
// on which slice was rendered, otherwise the label `val` is
// outside the chart's `[value_min, value_max]` window and the
// resulting `row_f` underflows when cast to usize.
const overlay_today_years: ?f64 = blk: {
if (!state.overlay_actuals) break :blk null;
const ctx_data = state.ctx orelse break :blk null;
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;
// 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;
@ -948,6 +1011,16 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [
for (label_values) |val| { for (label_values) |val| {
const norm = (val - state.value_min) / val_range; const norm = (val - state.value_min) / val_range;
const row_f = @as(f64, @floatFromInt(chart_row_start)) + (1.0 - norm) * rows_f; const row_f = @as(f64, @floatFromInt(chart_row_start)) + (1.0 - norm) * rows_f;
// Defensive: a label value outside [value_min, value_max]
// produces a row_f outside the chart strip, which would
// panic on @intFromFloat for usize. Skip silently the
// label just doesn't render, which is the right thing
// when the band value is off-chart. (This used to fire
// when the bands slice and the chart-render slice
// disagreed; both now read from the same `bands` local,
// but the guard is cheap insurance.)
if (!std.math.isFinite(row_f)) continue;
if (row_f < 0) continue;
const row: usize = @intFromFloat(@round(row_f)); const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue; if (row >= height) continue;