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 8c4d7e6de3
commit 5ab2600946
Signed by: lobo
GPG key ID: A7B62D657EF764F8
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.
**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.
@ -325,6 +325,7 @@ Default tab-local keybindings (only active on the matching tab):
| Projections | `d` | Set as-of date prompt |
| Projections | `Esc` | Clear as-of date |
| 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 | `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
faithfulness one notch. Pick whichever has the highest
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
@ -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
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)
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
/// chart.
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
@ -142,6 +150,15 @@ pub const State = struct {
/// meaningful when `as_of` is set; the action flashes a status
/// message and leaves this off otherwise.
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
/// 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 = .toggle_convergence, .key = .{ .codepoint = 'c' } },
.{ .action = .toggle_return_backtest, .key = .{ .codepoint = 'b' } },
.{ .action = .toggle_overlay_zoom, .key = .{ .codepoint = 'z' } },
},
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.overlay_actuals = "Toggle actuals overlay",
@ -217,6 +235,7 @@ pub const meta: framework.TabMeta(Action) = .{
.clear_as_of = "Clear as-of date",
.toggle_convergence = "Toggle convergence sub-view",
.toggle_return_backtest = "Toggle return back-test sub-view",
.toggle_overlay_zoom = "Toggle overlay zoom",
}),
.status_hints = &.{
.toggle_chart,
@ -371,6 +390,23 @@ pub const tab = struct {
state.chart_dirty = true;
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 horizons = config.getHorizons();
const last_idx = horizons.len - 1;
const bands = pctx.data.bands[last_idx] orelse return;
if (bands.len < 2) return;
const full_bands = pctx.data.bands[last_idx] orelse 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)
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| {
const norm = (val - state.value_min) / val_range;
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));
if (row >= height) continue;