From 5ab2600946e9f80d24767f8544c36a96a107e6b3 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 19 May 2026 13:58:57 -0700 Subject: [PATCH] get appropriate zoom level when overlaying actuals on the projections chart --- README.md | 3 +- TODO.md | 46 +++++++++++----------- src/tui/projections_tab.zig | 77 ++++++++++++++++++++++++++++++++++++- 3 files changed, 101 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index b9abe37..a3e0c6f 100644 --- a/README.md +++ b/README.md @@ -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 `/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 | diff --git a/TODO.md b/TODO.md index 4162a90..4ed85da 100644 --- a/TODO.md +++ b/TODO.md @@ -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 `) — 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 diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 5d9a77e..21e73a9 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -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;