get appropriate zoom level when overlaying actuals on the projections chart
This commit is contained in:
parent
8c4d7e6de3
commit
5ab2600946
3 changed files with 101 additions and 25 deletions
|
|
@ -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
46
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 <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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue