migrate projections tab to new framework
This commit is contained in:
parent
13145590d1
commit
afe9eacf1d
3 changed files with 311 additions and 186 deletions
130
src/tui.zig
130
src/tui.zig
|
|
@ -288,6 +288,7 @@ pub const TabStates = struct {
|
||||||
performance: performance_tab.State = .{},
|
performance: performance_tab.State = .{},
|
||||||
options: options_tab.State = .{},
|
options: options_tab.State = .{},
|
||||||
history: history_tab.State = .{},
|
history: history_tab.State = .{},
|
||||||
|
projections: projections_tab.State = .{},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Comptime registry of all tab modules conforming to the
|
/// Comptime registry of all tab modules conforming to the
|
||||||
|
|
@ -325,6 +326,7 @@ const tab_modules = .{
|
||||||
.performance = performance_tab,
|
.performance = performance_tab,
|
||||||
.options = options_tab,
|
.options = options_tab,
|
||||||
.history = history_tab,
|
.history = history_tab,
|
||||||
|
.projections = projections_tab,
|
||||||
};
|
};
|
||||||
|
|
||||||
comptime {
|
comptime {
|
||||||
|
|
@ -576,38 +578,7 @@ pub const App = struct {
|
||||||
account_search_cursor: usize = 0, // cursor within search_matches
|
account_search_cursor: usize = 0, // cursor within search_matches
|
||||||
|
|
||||||
// History tab state lives in `self.states.history` (see TabStates).
|
// History tab state lives in `self.states.history` (see TabStates).
|
||||||
|
// Projections tab state lives in `self.states.projections`.
|
||||||
// Projections tab state
|
|
||||||
projections_loaded: bool = false,
|
|
||||||
projections_disabled: bool = false,
|
|
||||||
projections_config: @import("analytics/projections.zig").UserConfig = .{},
|
|
||||||
projections_ctx: ?@import("views/projections.zig").ProjectionContext = null,
|
|
||||||
projections_horizon_idx: usize = 0,
|
|
||||||
projections_image_id: ?u32 = null, // Kitty graphics image ID for projection chart
|
|
||||||
projections_image_width: u16 = 0,
|
|
||||||
projections_image_height: u16 = 0,
|
|
||||||
projections_chart_dirty: bool = true,
|
|
||||||
projections_chart_visible: bool = true,
|
|
||||||
projections_events_enabled: bool = true,
|
|
||||||
projections_value_min: f64 = 0,
|
|
||||||
projections_value_max: f64 = 0,
|
|
||||||
/// When non-null, the projections tab renders against a historical
|
|
||||||
/// snapshot instead of the live portfolio. Set via the `d` popup
|
|
||||||
/// (parsed by `cli.parseAsOfDate`) and auto-snapped to the nearest
|
|
||||||
/// earlier available snapshot. Cleared by `D` or by committing
|
|
||||||
/// an empty / "live" input.
|
|
||||||
projections_as_of: ?zfin.Date = null,
|
|
||||||
/// When auto-snap kicked in, `projections_as_of` is the resolved
|
|
||||||
/// snapshot date but `projections_as_of_requested` remembers what
|
|
||||||
/// the user actually typed — surfaced in the tab header as a muted
|
|
||||||
/// "(requested X; snapped to Y, N days earlier)" note.
|
|
||||||
projections_as_of_requested: ?zfin.Date = null,
|
|
||||||
/// When true, the projections chart overlays the realized
|
|
||||||
/// portfolio trajectory (snapshots + imported_values) on top of
|
|
||||||
/// the percentile bands. Toggled by the `o` keybind. Only
|
|
||||||
/// meaningful when `projections_as_of` is set; the keybind
|
|
||||||
/// flashes a status message and leaves this off otherwise.
|
|
||||||
projections_overlay_actuals: bool = false,
|
|
||||||
|
|
||||||
// Mouse wheel debounce for cursor-based tabs (portfolio, options).
|
// Mouse wheel debounce for cursor-based tabs (portfolio, options).
|
||||||
// Terminals often send multiple wheel events per physical tick.
|
// Terminals often send multiple wheel events per physical tick.
|
||||||
|
|
@ -1029,21 +1000,19 @@ pub const App = struct {
|
||||||
self.input_len = 0;
|
self.input_len = 0;
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
self.projections_as_of = d;
|
self.states.projections.as_of = d;
|
||||||
self.projections_as_of_requested = null;
|
self.states.projections.as_of_requested = null;
|
||||||
var status_buf: [64]u8 = undefined;
|
var status_buf: [64]u8 = undefined;
|
||||||
const msg = std.fmt.bufPrint(&status_buf, "As-of: {f}", .{d}) catch "As-of set";
|
const msg = std.fmt.bufPrint(&status_buf, "As-of: {f}", .{d}) catch "As-of set";
|
||||||
self.setStatus(msg);
|
self.setStatus(msg);
|
||||||
} else {
|
} else {
|
||||||
// `null` parse result = live.
|
// `null` parse result = live.
|
||||||
self.projections_as_of = null;
|
self.states.projections.as_of = null;
|
||||||
self.projections_as_of_requested = null;
|
self.states.projections.as_of_requested = null;
|
||||||
self.setStatus("As-of cleared — showing live");
|
self.setStatus("As-of cleared — showing live");
|
||||||
}
|
}
|
||||||
|
|
||||||
projections_tab.freeLoaded(self);
|
projections_tab.tab.reload(&self.states.projections, self) catch {};
|
||||||
self.projections_loaded = false;
|
|
||||||
projections_tab.loadData(self);
|
|
||||||
|
|
||||||
self.mode = .normal;
|
self.mode = .normal;
|
||||||
self.input_len = 0;
|
self.input_len = 0;
|
||||||
|
|
@ -1302,13 +1271,11 @@ pub const App = struct {
|
||||||
self.setStatus("Filter cleared: showing all accounts");
|
self.setStatus("Filter cleared: showing all accounts");
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
if (self.active_tab == .projections and self.projections_as_of != null) {
|
if (self.active_tab == .projections and self.states.projections.as_of != null) {
|
||||||
self.projections_as_of = null;
|
self.states.projections.as_of = null;
|
||||||
self.projections_as_of_requested = null;
|
self.states.projections.as_of_requested = null;
|
||||||
self.projections_overlay_actuals = false;
|
self.states.projections.overlay_actuals = false;
|
||||||
projections_tab.freeLoaded(self);
|
projections_tab.tab.reload(&self.states.projections, self) catch {};
|
||||||
self.projections_loaded = false;
|
|
||||||
projections_tab.loadData(self);
|
|
||||||
self.setStatus("As-of cleared — showing live");
|
self.setStatus("As-of cleared — showing live");
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
|
|
@ -1516,24 +1483,7 @@ pub const App = struct {
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
if (self.active_tab == .projections) {
|
if (self.active_tab == .projections) {
|
||||||
if (self.projections_as_of == null) {
|
projections_tab.tab.handleAction(&self.states.projections, self, projections_tab.Action.overlay_actuals);
|
||||||
self.setStatus("Overlay only available with --as-of (press d to set)");
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
self.projections_overlay_actuals = !self.projections_overlay_actuals;
|
|
||||||
// Re-run loadData so the overlay section gets
|
|
||||||
// built (or freed). The timeline load is the
|
|
||||||
// expensive bit but it's rare — humans toggle
|
|
||||||
// this maybe a few times per session.
|
|
||||||
projections_tab.freeLoaded(self);
|
|
||||||
self.projections_loaded = false;
|
|
||||||
projections_tab.loadData(self);
|
|
||||||
self.projections_chart_dirty = true;
|
|
||||||
if (self.projections_overlay_actuals) {
|
|
||||||
self.setStatus("Overlay: ON — tracks trajectory, not SWR validity");
|
|
||||||
} else {
|
|
||||||
self.setStatus("Overlay: OFF");
|
|
||||||
}
|
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1555,20 +1505,13 @@ pub const App = struct {
|
||||||
},
|
},
|
||||||
.toggle_chart => {
|
.toggle_chart => {
|
||||||
if (self.active_tab == .projections) {
|
if (self.active_tab == .projections) {
|
||||||
self.projections_chart_visible = !self.projections_chart_visible;
|
projections_tab.tab.handleAction(&self.states.projections, self, projections_tab.Action.toggle_chart);
|
||||||
self.projections_chart_dirty = true;
|
|
||||||
self.scroll_offset = 0;
|
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.toggle_events => {
|
.toggle_events => {
|
||||||
if (self.active_tab == .projections) {
|
if (self.active_tab == .projections) {
|
||||||
self.projections_events_enabled = !self.projections_events_enabled;
|
projections_tab.tab.handleAction(&self.states.projections, self, projections_tab.Action.toggle_events);
|
||||||
projections_tab.freeLoaded(self);
|
|
||||||
self.projections_loaded = false;
|
|
||||||
projections_tab.loadData(self);
|
|
||||||
const label = if (self.projections_events_enabled) "Events enabled" else "Events disabled";
|
|
||||||
self.setStatus(label);
|
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1577,10 +1520,7 @@ pub const App = struct {
|
||||||
// let the same key flow to their own handlers (none
|
// let the same key flow to their own handlers (none
|
||||||
// currently bind plain 'd').
|
// currently bind plain 'd').
|
||||||
if (self.active_tab == .projections) {
|
if (self.active_tab == .projections) {
|
||||||
self.mode = .date_input;
|
projections_tab.tab.handleAction(&self.states.projections, self, projections_tab.Action.as_of_input);
|
||||||
self.input_len = 0;
|
|
||||||
// No setStatus — drawStatusBar replaces the whole
|
|
||||||
// line with the prompt + hint when mode is .date_input.
|
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1781,8 +1721,7 @@ pub const App = struct {
|
||||||
history_tab.tab.reload(&self.states.history, self) catch {};
|
history_tab.tab.reload(&self.states.history, self) catch {};
|
||||||
},
|
},
|
||||||
.projections => {
|
.projections => {
|
||||||
self.projections_loaded = false;
|
projections_tab.tab.reload(&self.states.projections, self) catch {};
|
||||||
projections_tab.freeLoaded(self);
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.loadTabData();
|
self.loadTabData();
|
||||||
|
|
@ -1828,8 +1767,7 @@ pub const App = struct {
|
||||||
history_tab.tab.activate(&self.states.history, self) catch {};
|
history_tab.tab.activate(&self.states.history, self) catch {};
|
||||||
},
|
},
|
||||||
.projections => {
|
.projections => {
|
||||||
if (self.projections_disabled) return;
|
projections_tab.tab.activate(&self.states.projections, self) catch {};
|
||||||
if (!self.projections_loaded) projections_tab.loadData(self);
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1884,7 +1822,7 @@ pub const App = struct {
|
||||||
analysis_tab.tab.deinit(&self.states.analysis, self);
|
analysis_tab.tab.deinit(&self.states.analysis, self);
|
||||||
self.portfolio.deinit(self.allocator);
|
self.portfolio.deinit(self.allocator);
|
||||||
history_tab.tab.deinit(&self.states.history, self);
|
history_tab.tab.deinit(&self.states.history, self);
|
||||||
projections_tab.freeLoaded(self);
|
projections_tab.tab.deinit(&self.states.projections, self);
|
||||||
quote_tab.tab.deinit(&self.states.quote, self);
|
quote_tab.tab.deinit(&self.states.quote, self);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1961,15 +1899,12 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the given tab should be treated as disabled in
|
/// Whether the given tab should be treated as disabled in
|
||||||
/// the current App context. Migrated tabs are consulted via
|
/// the current App context. All migrated tabs are consulted
|
||||||
/// their framework-contract `isDisabled` hook; unmigrated tabs
|
/// via their framework-contract `isDisabled` hook. (Portfolio
|
||||||
/// (projections) still use bespoke `_disabled` flags set during
|
/// is the only remaining unmigrated tab; it has no disabled
|
||||||
/// App init. The unmigrated branch goes away when projections
|
/// predicate today.)
|
||||||
/// adopts the framework — the `_disabled` field will be deleted
|
|
||||||
/// alongside the inline check here.
|
|
||||||
fn isDisabled(self: *App, t: Tab) bool {
|
fn isDisabled(self: *App, t: Tab) bool {
|
||||||
return self.appPredicate(t, "isDisabled") or
|
return self.appPredicate(t, "isDisabled");
|
||||||
(t == .projections and self.projections_disabled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn isSymbolSelected(self: *App) bool {
|
fn isSymbolSelected(self: *App) bool {
|
||||||
|
|
@ -2016,7 +1951,7 @@ pub const App = struct {
|
||||||
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
|
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
|
||||||
try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]);
|
try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]);
|
||||||
},
|
},
|
||||||
.projections => try projections_tab.drawContent(self, ctx, buf, width, height),
|
.projections => try projections_tab.drawContent(&self.states.projections, self, ctx, buf, width, height),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2539,15 +2474,6 @@ pub fn run(
|
||||||
app_inst.active_tab = .quote;
|
app_inst.active_tab = .quote;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable projections tab when no portfolio is loaded.
|
|
||||||
// Analysis derives the same condition via its `isDisabled`
|
|
||||||
// method (no field write needed) — the predicate is computed
|
|
||||||
// from `app.portfolio.file == null` directly. History does the
|
|
||||||
// same — see `history_tab.tab.isDisabled`.
|
|
||||||
if (app_inst.portfolio.file == null) {
|
|
||||||
app_inst.projections_disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-fetch portfolio prices before TUI starts, with stderr progress.
|
// Pre-fetch portfolio prices before TUI starts, with stderr progress.
|
||||||
// This runs while the terminal is still in normal mode so output is visible.
|
// This runs while the terminal is still in normal mode so output is visible.
|
||||||
if (app_inst.portfolio.file) |pf| {
|
if (app_inst.portfolio.file) |pf| {
|
||||||
|
|
@ -2621,9 +2547,9 @@ pub fn run(
|
||||||
vx_app.vx.freeImage(vx_app.tty.writer(), id);
|
vx_app.vx.freeImage(vx_app.tty.writer(), id);
|
||||||
app_inst.states.quote.chart.image_id = null;
|
app_inst.states.quote.chart.image_id = null;
|
||||||
}
|
}
|
||||||
if (app_inst.projections_image_id) |id| {
|
if (app_inst.states.projections.image_id) |id| {
|
||||||
vx_app.vx.freeImage(vx_app.tty.writer(), id);
|
vx_app.vx.freeImage(vx_app.tty.writer(), id);
|
||||||
app_inst.projections_image_id = null;
|
app_inst.states.projections.image_id = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try vx_app.run(app_inst.widget(), .{});
|
try vx_app.run(app_inst.widget(), .{});
|
||||||
|
|
|
||||||
|
|
@ -1297,12 +1297,15 @@ pub fn reloadPortfolioFile(app: *App) void {
|
||||||
analysis_tab.tab.activate(&app.states.analysis, app) catch {};
|
analysis_tab.tab.activate(&app.states.analysis, app) catch {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate projections data — projections.srf may have changed
|
// Invalidate projections data — projections.srf may have changed.
|
||||||
projections_tab.freeLoaded(app);
|
// Always drop the cached context so a stale render doesn't leak;
|
||||||
app.projections_loaded = false;
|
// re-fetch only if the user is actively looking at projections.
|
||||||
app.projections_disabled = false;
|
// (When not active, the next `activate` lazily re-fetches.)
|
||||||
if (app.active_tab == .projections) {
|
if (app.active_tab == .projections) {
|
||||||
projections_tab.loadData(app);
|
projections_tab.tab.reload(&app.states.projections, app) catch {};
|
||||||
|
} else {
|
||||||
|
projections_tab.freeLoaded(&app.states.projections, app);
|
||||||
|
app.states.projections.loaded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missing > 0) {
|
if (missing > 0) {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
//!
|
//!
|
||||||
//! ## As-of mode
|
//! ## As-of mode
|
||||||
//!
|
//!
|
||||||
//! When `app.projections_as_of` is non-null, the tab renders against a
|
//! When `state.as_of` is non-null, the tab renders against a
|
||||||
//! historical snapshot instead of the live portfolio, using
|
//! historical snapshot instead of the live portfolio, using
|
||||||
//! `view.loadProjectionContextAsOf`. The user toggles this via the `d`
|
//! `view.loadProjectionContextAsOf`. The user toggles this via the `d`
|
||||||
//! keybind (date popup) or `D` (return to live). Auto-snaps to the
|
//! keybind (date popup) or `D` (return to live). Auto-snaps to the
|
||||||
|
|
@ -34,14 +34,210 @@ const performance = @import("../analytics/performance.zig");
|
||||||
const valuation = @import("../analytics/valuation.zig");
|
const valuation = @import("../analytics/valuation.zig");
|
||||||
const view = @import("../views/projections.zig");
|
const view = @import("../views/projections.zig");
|
||||||
const history = @import("../history.zig");
|
const history = @import("../history.zig");
|
||||||
|
const framework = @import("tab_framework.zig");
|
||||||
const App = tui.App;
|
const App = tui.App;
|
||||||
const StyledLine = tui.StyledLine;
|
const StyledLine = tui.StyledLine;
|
||||||
|
|
||||||
|
// ── Tab-local action enum ─────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Projections tab keybinds (today routed through the legacy
|
||||||
|
// global `keybinds.Action` variants and the central tui.zig
|
||||||
|
// switch — `o` → sort_reverse, `c` → toggle_chart, `b` →
|
||||||
|
// toggle_events, `d` → projections_as_of_input. When scoped
|
||||||
|
// keymaps land (TODO step 3), these become genuinely tab-local
|
||||||
|
// and the global enum variants for them disappear.
|
||||||
|
|
||||||
|
pub const Action = enum {
|
||||||
|
/// Toggle the as-of-anchored actuals overlay on the projection
|
||||||
|
/// chart. No-op (with status hint) when not in as-of mode.
|
||||||
|
/// Today bound to `o` via the global `sort_reverse` action.
|
||||||
|
overlay_actuals,
|
||||||
|
/// Show / hide the percentile-band chart. Toggles
|
||||||
|
/// `state.chart_visible`; doesn't reload data.
|
||||||
|
toggle_chart,
|
||||||
|
/// Enable / disable simulated lifecycle events (RMDs, lump-sum
|
||||||
|
/// withdrawals). Forces a reload because the simulation engine
|
||||||
|
/// re-runs with the new flag.
|
||||||
|
toggle_events,
|
||||||
|
/// Open the as-of date input mini-popup. Mode transition is
|
||||||
|
/// handled by the App; the popup commit / clear path lives in
|
||||||
|
/// tui.zig and calls `loadData` on this tab.
|
||||||
|
as_of_input,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tab-private state ─────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const State = struct {
|
||||||
|
/// Whether `activate` has populated `ctx` (or set up
|
||||||
|
/// disabled state). Distinct from `ctx != null` because failed
|
||||||
|
/// loads still mark loaded.
|
||||||
|
loaded: bool = false,
|
||||||
|
/// User-tunable inputs to the projection engine (annual
|
||||||
|
/// contribution, target spending, retirement target percentile,
|
||||||
|
/// etc.). Driven by annotations on the portfolio file.
|
||||||
|
config: @import("../analytics/projections.zig").UserConfig = .{},
|
||||||
|
/// Loaded projection context: bands, withdrawal tables,
|
||||||
|
/// horizon configs, optional overlay actuals. Owned by State;
|
||||||
|
/// freed via `freeLoaded`.
|
||||||
|
ctx: ?@import("../views/projections.zig").ProjectionContext = null,
|
||||||
|
/// Currently-focused horizon row in the terminal-value table.
|
||||||
|
/// (Reserved for future expansion; not consumed today.)
|
||||||
|
horizon_idx: usize = 0,
|
||||||
|
/// Kitty graphics image id for the percentile-band chart, when
|
||||||
|
/// using the Kitty path. Null when no image is currently
|
||||||
|
/// transmitted to the terminal.
|
||||||
|
image_id: ?u32 = null,
|
||||||
|
/// Pixel dimensions of the most-recently-transmitted image
|
||||||
|
/// (used to detect resize and re-render).
|
||||||
|
image_width: u16 = 0,
|
||||||
|
image_height: u16 = 0,
|
||||||
|
/// True when the chart needs re-rendering on the next draw.
|
||||||
|
/// Set by `freeLoaded`, by toggle_chart, by overlay_actuals
|
||||||
|
/// changes, etc.
|
||||||
|
chart_dirty: bool = true,
|
||||||
|
/// Whether the percentile-band chart is shown. Toggled by
|
||||||
|
/// `toggle_chart`. When false, the tab renders text-only with
|
||||||
|
/// scroll (the full report).
|
||||||
|
chart_visible: bool = true,
|
||||||
|
/// Whether simulated lifecycle events (RMDs, lump-sum
|
||||||
|
/// withdrawals) are included in the projection. Toggled by
|
||||||
|
/// `toggle_events`; flipping forces a reload.
|
||||||
|
events_enabled: bool = true,
|
||||||
|
/// Y-axis bounds last used for the chart — informational, not
|
||||||
|
/// load-bearing in dispatch.
|
||||||
|
value_min: f64 = 0,
|
||||||
|
value_max: f64 = 0,
|
||||||
|
/// When non-null, the projections tab renders against a historical
|
||||||
|
/// snapshot instead of the live portfolio. Set via the `d` popup
|
||||||
|
/// (parsed by `cli.parseAsOfDate`) and auto-snapped to the nearest
|
||||||
|
/// earlier available snapshot. Cleared by Esc on the projections
|
||||||
|
/// tab when set, or by committing an empty / "live" input.
|
||||||
|
as_of: ?zfin.Date = null,
|
||||||
|
/// When auto-snap kicked in, `as_of` is the resolved snapshot
|
||||||
|
/// date but `as_of_requested` remembers what the user actually
|
||||||
|
/// typed — surfaced in the tab header as a muted "(requested X;
|
||||||
|
/// snapped to Y, N days earlier)" note.
|
||||||
|
as_of_requested: ?zfin.Date = null,
|
||||||
|
/// When true, the projections chart overlays the realized
|
||||||
|
/// portfolio trajectory (snapshots + imported_values) on top of
|
||||||
|
/// the percentile bands. Toggled by `overlay_actuals`. Only
|
||||||
|
/// meaningful when `as_of` is set; the action flashes a status
|
||||||
|
/// message and leaves this off otherwise.
|
||||||
|
overlay_actuals: bool = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tab framework contract ────────────────────────────────────
|
||||||
|
|
||||||
|
pub const tab = struct {
|
||||||
|
pub const ActionT = Action;
|
||||||
|
pub const StateT = State;
|
||||||
|
|
||||||
|
pub const default_bindings: []const framework.TabBinding(Action) = &.{
|
||||||
|
// Today's keybinds are in the global keymap (sort_reverse,
|
||||||
|
// toggle_chart, toggle_events, projections_as_of_input).
|
||||||
|
// These per-tab declarations become authoritative when
|
||||||
|
// scoped keymaps land.
|
||||||
|
.{ .action = .overlay_actuals, .key = .{ .codepoint = 'o' } },
|
||||||
|
.{ .action = .toggle_chart, .key = .{ .codepoint = 'c' } },
|
||||||
|
.{ .action = .toggle_events, .key = .{ .codepoint = 'b' } },
|
||||||
|
.{ .action = .as_of_input, .key = .{ .codepoint = 'd' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{
|
||||||
|
.overlay_actuals = "Toggle actuals overlay",
|
||||||
|
.toggle_chart = "Toggle chart visibility",
|
||||||
|
.toggle_events = "Toggle lifecycle events",
|
||||||
|
.as_of_input = "Set as-of date",
|
||||||
|
});
|
||||||
|
|
||||||
|
pub const status_hints: []const Action = &.{
|
||||||
|
.toggle_chart,
|
||||||
|
.toggle_events,
|
||||||
|
.as_of_input,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn init(state: *State, app: *App) !void {
|
||||||
|
_ = app;
|
||||||
|
state.* = .{};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deinit(state: *State, app: *App) void {
|
||||||
|
freeLoaded(state, app);
|
||||||
|
state.* = .{};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn activate(state: *State, app: *App) !void {
|
||||||
|
if (state.loaded) return;
|
||||||
|
loadData(state, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const deactivate = framework.noopDeactivate(State);
|
||||||
|
|
||||||
|
pub fn reload(state: *State, app: *App) !void {
|
||||||
|
state.loaded = false;
|
||||||
|
freeLoaded(state, app);
|
||||||
|
loadData(state, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const tick = framework.noopTick(State);
|
||||||
|
|
||||||
|
pub fn handleAction(state: *State, app: *App, action: Action) void {
|
||||||
|
switch (action) {
|
||||||
|
.overlay_actuals => {
|
||||||
|
if (state.as_of == null) {
|
||||||
|
app.setStatus("Overlay only available with --as-of (press d to set)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.overlay_actuals = !state.overlay_actuals;
|
||||||
|
// Re-run loadData so the overlay section gets built
|
||||||
|
// (or freed). The timeline load is the expensive
|
||||||
|
// bit but it's rare — humans toggle this maybe a
|
||||||
|
// few times per session.
|
||||||
|
freeLoaded(state, app);
|
||||||
|
state.loaded = false;
|
||||||
|
loadData(state, app);
|
||||||
|
state.chart_dirty = true;
|
||||||
|
if (state.overlay_actuals) {
|
||||||
|
app.setStatus("Overlay: ON — tracks trajectory, not SWR validity");
|
||||||
|
} else {
|
||||||
|
app.setStatus("Overlay: OFF");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.toggle_chart => {
|
||||||
|
state.chart_visible = !state.chart_visible;
|
||||||
|
state.chart_dirty = true;
|
||||||
|
app.scroll_offset = 0;
|
||||||
|
},
|
||||||
|
.toggle_events => {
|
||||||
|
state.events_enabled = !state.events_enabled;
|
||||||
|
freeLoaded(state, app);
|
||||||
|
state.loaded = false;
|
||||||
|
loadData(state, app);
|
||||||
|
const label = if (state.events_enabled) "Events enabled" else "Events disabled";
|
||||||
|
app.setStatus(label);
|
||||||
|
},
|
||||||
|
.as_of_input => {
|
||||||
|
app.mode = .date_input;
|
||||||
|
app.input_len = 0;
|
||||||
|
// No setStatus — drawStatusBar replaces the whole
|
||||||
|
// line with the prompt + hint when mode is .date_input.
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Projections requires a loaded portfolio (the simulation
|
||||||
|
/// engine reads lots / allocations from `app.portfolio`). Same
|
||||||
|
/// predicate as analysis_tab and history_tab.
|
||||||
|
pub fn isDisabled(app: *App) bool {
|
||||||
|
return app.portfolio.file == null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ── Data loading ──────────────────────────────────────────────
|
// ── Data loading ──────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn loadData(app: *App) void {
|
pub fn loadData(state: *State, app: *App) void {
|
||||||
app.projections_loaded = true;
|
state.loaded = true;
|
||||||
freeLoaded(app);
|
freeLoaded(state, app);
|
||||||
|
|
||||||
const portfolio_path = app.portfolio_path orelse {
|
const portfolio_path = app.portfolio_path orelse {
|
||||||
app.setStatus("Projections tab requires a loaded portfolio");
|
app.setStatus("Projections tab requires a loaded portfolio");
|
||||||
|
|
@ -61,25 +257,25 @@ pub fn loadData(app: *App) void {
|
||||||
// message explaining why, and fall through to the live path so
|
// message explaining why, and fall through to the live path so
|
||||||
// the tab still shows something rather than going blank.
|
// the tab still shows something rather than going blank.
|
||||||
as_of: {
|
as_of: {
|
||||||
const requested_date = app.projections_as_of orelse break :as_of;
|
const requested_date = state.as_of orelse break :as_of;
|
||||||
|
|
||||||
const resolution = resolveAsOf(app, portfolio_path, requested_date) orelse {
|
const resolution = resolveAsOf(state, app, portfolio_path, requested_date) orelse {
|
||||||
// `setStatus` already called by resolveAsOf.
|
// `setStatus` already called by resolveAsOf.
|
||||||
app.projections_as_of = null;
|
state.as_of = null;
|
||||||
app.projections_as_of_requested = null;
|
state.as_of_requested = null;
|
||||||
break :as_of;
|
break :as_of;
|
||||||
};
|
};
|
||||||
const actual_date = resolution.actual;
|
const actual_date = resolution.actual;
|
||||||
app.projections_as_of = actual_date;
|
state.as_of = actual_date;
|
||||||
// Preserve requested for the header note; clear if it matches actual.
|
// Preserve requested for the header note; clear if it matches actual.
|
||||||
if (actual_date.eql(requested_date)) {
|
if (actual_date.eql(requested_date)) {
|
||||||
app.projections_as_of_requested = null;
|
state.as_of_requested = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hist_dir = history.deriveHistoryDir(app.allocator, portfolio_path) catch {
|
const hist_dir = history.deriveHistoryDir(app.allocator, portfolio_path) catch {
|
||||||
app.setStatus("Failed to derive history dir — showing live");
|
app.setStatus("Failed to derive history dir — showing live");
|
||||||
app.projections_as_of = null;
|
state.as_of = null;
|
||||||
app.projections_as_of_requested = null;
|
state.as_of_requested = null;
|
||||||
break :as_of;
|
break :as_of;
|
||||||
};
|
};
|
||||||
defer app.allocator.free(hist_dir);
|
defer app.allocator.free(hist_dir);
|
||||||
|
|
@ -88,8 +284,8 @@ pub fn loadData(app: *App) void {
|
||||||
.snapshot => snap: {
|
.snapshot => snap: {
|
||||||
var loaded = history.loadSnapshotAt(app.io, app.allocator, hist_dir, actual_date) catch {
|
var loaded = history.loadSnapshotAt(app.io, app.allocator, hist_dir, actual_date) catch {
|
||||||
app.setStatus("Failed to load snapshot — showing live");
|
app.setStatus("Failed to load snapshot — showing live");
|
||||||
app.projections_as_of = null;
|
state.as_of = null;
|
||||||
app.projections_as_of_requested = null;
|
state.as_of_requested = null;
|
||||||
break :as_of;
|
break :as_of;
|
||||||
};
|
};
|
||||||
defer loaded.deinit(app.allocator);
|
defer loaded.deinit(app.allocator);
|
||||||
|
|
@ -101,11 +297,11 @@ pub fn loadData(app: *App) void {
|
||||||
&loaded.snap,
|
&loaded.snap,
|
||||||
actual_date,
|
actual_date,
|
||||||
app.svc,
|
app.svc,
|
||||||
app.projections_events_enabled,
|
state.events_enabled,
|
||||||
) catch {
|
) catch {
|
||||||
app.setStatus("Failed to compute as-of projections — showing live");
|
app.setStatus("Failed to compute as-of projections — showing live");
|
||||||
app.projections_as_of = null;
|
state.as_of = null;
|
||||||
app.projections_as_of_requested = null;
|
state.as_of_requested = null;
|
||||||
break :as_of;
|
break :as_of;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
@ -116,14 +312,14 @@ pub fn loadData(app: *App) void {
|
||||||
// up-front into `app.portfolio.summary`.
|
// up-front into `app.portfolio.summary`.
|
||||||
const summary = app.portfolio.summary orelse {
|
const summary = app.portfolio.summary orelse {
|
||||||
app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
|
app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
|
||||||
app.projections_as_of = null;
|
state.as_of = null;
|
||||||
app.projections_as_of_requested = null;
|
state.as_of_requested = null;
|
||||||
break :as_of;
|
break :as_of;
|
||||||
};
|
};
|
||||||
const portfolio = app.portfolio.file orelse {
|
const portfolio = app.portfolio.file orelse {
|
||||||
app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
|
app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
|
||||||
app.projections_as_of = null;
|
state.as_of = null;
|
||||||
app.projections_as_of_requested = null;
|
state.as_of_requested = null;
|
||||||
break :as_of;
|
break :as_of;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -138,11 +334,11 @@ pub fn loadData(app: *App) void {
|
||||||
resolution.liquid,
|
resolution.liquid,
|
||||||
actual_date,
|
actual_date,
|
||||||
app.svc,
|
app.svc,
|
||||||
app.projections_events_enabled,
|
state.events_enabled,
|
||||||
) catch {
|
) catch {
|
||||||
app.setStatus("Failed to compute as-of projections — showing live");
|
app.setStatus("Failed to compute as-of projections — showing live");
|
||||||
app.projections_as_of = null;
|
state.as_of = null;
|
||||||
app.projections_as_of_requested = null;
|
state.as_of_requested = null;
|
||||||
break :as_of;
|
break :as_of;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
@ -152,7 +348,7 @@ pub fn loadData(app: *App) void {
|
||||||
// Attach the actuals overlay if the toggle is on. Failures
|
// Attach the actuals overlay if the toggle is on. Failures
|
||||||
// here are non-fatal — the chart still renders without the
|
// here are non-fatal — the chart still renders without the
|
||||||
// overlay; the toggle stays on so the user knows the intent.
|
// overlay; the toggle stays on so the user knows the intent.
|
||||||
if (app.projections_overlay_actuals) {
|
if (state.overlay_actuals) {
|
||||||
if (loadOverlayActuals(app, portfolio_path, actual_date)) |ov| {
|
if (loadOverlayActuals(app, portfolio_path, actual_date)) |ov| {
|
||||||
ctx_with_overlay.overlay_actuals = ov;
|
ctx_with_overlay.overlay_actuals = ov;
|
||||||
} else |_| {
|
} else |_| {
|
||||||
|
|
@ -162,7 +358,7 @@ pub fn loadData(app: *App) void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.projections_ctx = ctx_with_overlay;
|
state.ctx = ctx_with_overlay;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,14 +380,14 @@ pub fn loadData(app: *App) void {
|
||||||
portfolio.totalCash(app.today),
|
portfolio.totalCash(app.today),
|
||||||
portfolio.totalCdFaceValue(app.today),
|
portfolio.totalCdFaceValue(app.today),
|
||||||
app.svc,
|
app.svc,
|
||||||
app.projections_events_enabled,
|
state.events_enabled,
|
||||||
app.today,
|
app.today,
|
||||||
) catch {
|
) catch {
|
||||||
app.setStatus("Failed to compute projections");
|
app.setStatus("Failed to compute projections");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
app.projections_ctx = ctx;
|
state.ctx = ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the user's requested as-of date against the portfolio's
|
/// Resolve the user's requested as-of date against the portfolio's
|
||||||
|
|
@ -202,7 +398,7 @@ pub fn loadData(app: *App) void {
|
||||||
/// Thin adapter over `history.resolveAsOfDate` — the shared pure
|
/// Thin adapter over `history.resolveAsOfDate` — the shared pure
|
||||||
/// resolver owns exact-then-fallback logic; this wrapper maps its
|
/// resolver owns exact-then-fallback logic; this wrapper maps its
|
||||||
/// errors to user-visible status-bar messages and handles the arena.
|
/// errors to user-visible status-bar messages and handles the arena.
|
||||||
fn resolveAsOf(app: *App, portfolio_path: []const u8, requested: zfin.Date) ?history.ResolvedAsOf {
|
fn resolveAsOf(state: *State, app: *App, portfolio_path: []const u8, requested: zfin.Date) ?history.ResolvedAsOf {
|
||||||
var arena_state = std.heap.ArenaAllocator.init(app.allocator);
|
var arena_state = std.heap.ArenaAllocator.init(app.allocator);
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
|
|
@ -231,7 +427,7 @@ fn resolveAsOf(app: *App, portfolio_path: []const u8, requested: zfin.Date) ?his
|
||||||
|
|
||||||
if (!resolved.exact) {
|
if (!resolved.exact) {
|
||||||
// Remember the original request for the muted header note.
|
// Remember the original request for the muted header note.
|
||||||
app.projections_as_of_requested = requested;
|
state.as_of_requested = requested;
|
||||||
}
|
}
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
@ -246,8 +442,8 @@ fn loadOverlayActuals(app: *App, portfolio_path: []const u8, as_of: zfin.Date) !
|
||||||
return try view.buildOverlayActuals(app.allocator, loaded.series.points, as_of, app.today);
|
return try view.buildOverlayActuals(app.allocator, loaded.series.points, as_of, app.today);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn freeLoaded(app: *App) void {
|
pub fn freeLoaded(state: *State, app: *App) void {
|
||||||
if (app.projections_ctx) |*ctx| {
|
if (state.ctx) |*ctx| {
|
||||||
app.allocator.free(ctx.data.withdrawals);
|
app.allocator.free(ctx.data.withdrawals);
|
||||||
for (ctx.data.bands) |b| {
|
for (ctx.data.bands) |b| {
|
||||||
if (b) |slice| app.allocator.free(slice);
|
if (b) |slice| app.allocator.free(slice);
|
||||||
|
|
@ -256,14 +452,14 @@ pub fn freeLoaded(app: *App) void {
|
||||||
if (ctx.earliest) |er| app.allocator.free(er);
|
if (ctx.earliest) |er| app.allocator.free(er);
|
||||||
if (ctx.overlay_actuals) |*ov| ov.deinit();
|
if (ctx.overlay_actuals) |*ov| ov.deinit();
|
||||||
}
|
}
|
||||||
app.projections_ctx = null;
|
state.ctx = null;
|
||||||
// Mark projection chart as dirty so it re-renders on next draw
|
// Mark projection chart as dirty so it re-renders on next draw
|
||||||
app.projections_chart_dirty = true;
|
state.chart_dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rendering ─────────────────────────────────────────────────
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
pub fn drawContent(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||||
const arena = ctx.arena;
|
const arena = ctx.arena;
|
||||||
|
|
||||||
// Determine whether to use Kitty graphics
|
// Determine whether to use Kitty graphics
|
||||||
|
|
@ -274,7 +470,7 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi
|
||||||
};
|
};
|
||||||
|
|
||||||
// Need bands data for the chart
|
// Need bands data for the chart
|
||||||
const has_bands = if (app.projections_ctx) |pctx| blk: {
|
const has_bands = if (state.ctx) |pctx| blk: {
|
||||||
const horizons = pctx.config.getHorizons();
|
const horizons = pctx.config.getHorizons();
|
||||||
if (horizons.len == 0) break :blk false;
|
if (horizons.len == 0) break :blk false;
|
||||||
const last_idx = horizons.len - 1;
|
const last_idx = horizons.len - 1;
|
||||||
|
|
@ -284,27 +480,27 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi
|
||||||
break :blk false;
|
break :blk false;
|
||||||
} else false;
|
} else false;
|
||||||
|
|
||||||
if (use_kitty and has_bands and app.projections_chart_visible) {
|
if (use_kitty and has_bands and state.chart_visible) {
|
||||||
drawWithKittyChart(app, ctx, buf, width, height) catch {
|
drawWithKittyChart(state, app, ctx, buf, width, height) catch {
|
||||||
try drawWithScroll(app, arena, buf, width, height);
|
try drawWithScroll(state, app, arena, buf, width, height);
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
try drawWithScroll(app, arena, buf, width, height);
|
try drawWithScroll(state, app, arena, buf, width, height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render styled lines with scroll_offset applied.
|
/// Render styled lines with scroll_offset applied.
|
||||||
fn drawWithScroll(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
fn drawWithScroll(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||||
const all_lines = try buildStyledLines(app, arena);
|
const all_lines = try buildStyledLines(state, app, arena);
|
||||||
const start = @min(app.scroll_offset, if (all_lines.len > 0) all_lines.len - 1 else 0);
|
const start = @min(app.scroll_offset, if (all_lines.len > 0) all_lines.len - 1 else 0);
|
||||||
try app.drawStyledContent(arena, buf, width, height, all_lines[start..]);
|
try app.drawStyledContent(arena, buf, width, height, all_lines[start..]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw projections tab using Kitty graphics protocol for the percentile band chart.
|
/// Draw projections tab using Kitty graphics protocol for the percentile band chart.
|
||||||
fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
fn drawWithKittyChart(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||||
const arena = ctx.arena;
|
const arena = ctx.arena;
|
||||||
const th = app.theme;
|
const th = app.theme;
|
||||||
const pctx = app.projections_ctx orelse return;
|
const pctx = state.ctx orelse return;
|
||||||
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;
|
||||||
|
|
@ -313,7 +509,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
||||||
|
|
||||||
// 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;
|
||||||
try buildHeaderSection(app, arena, &header_lines, pctx);
|
try buildHeaderSection(state, app, arena, &header_lines, pctx);
|
||||||
|
|
||||||
// Chart title
|
// Chart title
|
||||||
try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
@ -338,7 +534,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
||||||
const chart_rows = height -| header_rows -| footer_reserve;
|
const chart_rows = height -| header_rows -| footer_reserve;
|
||||||
if (chart_rows < 6) {
|
if (chart_rows < 6) {
|
||||||
// Not enough space for chart — fall back to text-only with scroll
|
// Not enough space for chart — fall back to text-only with scroll
|
||||||
try drawWithScroll(app, arena, buf, width, height);
|
try drawWithScroll(state, app, arena, buf, width, height);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,13 +552,13 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
||||||
const capped_h = @min(px_h, app.chart_config.max_height);
|
const capped_h = @min(px_h, app.chart_config.max_height);
|
||||||
|
|
||||||
// Render or reuse cached image
|
// Render or reuse cached image
|
||||||
if (app.projections_chart_dirty) {
|
if (state.chart_dirty) {
|
||||||
// Free old image
|
// Free old image
|
||||||
if (app.projections_image_id) |old_id| {
|
if (state.image_id) |old_id| {
|
||||||
if (app.vx_app) |va| {
|
if (app.vx_app) |va| {
|
||||||
va.vx.freeImage(va.tty.writer(), old_id);
|
va.vx.freeImage(va.tty.writer(), old_id);
|
||||||
}
|
}
|
||||||
app.projections_image_id = null;
|
state.image_id = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app.vx_app) |va| {
|
if (app.vx_app) |va| {
|
||||||
|
|
@ -377,8 +573,8 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
||||||
// dirty redraw because the overlay is at most ~12 years
|
// dirty redraw because the overlay is at most ~12 years
|
||||||
// of weekly data (~600 points).
|
// of weekly data (~600 points).
|
||||||
const overlay_input: ?projection_chart.ActualsOverlay = blk: {
|
const overlay_input: ?projection_chart.ActualsOverlay = blk: {
|
||||||
if (!app.projections_overlay_actuals) break :blk null;
|
if (!state.overlay_actuals) break :blk null;
|
||||||
const ctx_data = app.projections_ctx orelse break :blk null;
|
const ctx_data = state.ctx orelse break :blk null;
|
||||||
const ov = ctx_data.overlay_actuals orelse break :blk null;
|
const ov = ctx_data.overlay_actuals orelse break :blk null;
|
||||||
const ov_buf = app.allocator.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk null;
|
const ov_buf = app.allocator.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk null;
|
||||||
for (ov.points, 0..) |p, idx| {
|
for (ov.points, 0..) |p, idx| {
|
||||||
|
|
@ -397,7 +593,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
||||||
th,
|
th,
|
||||||
overlay_input,
|
overlay_input,
|
||||||
) catch {
|
) catch {
|
||||||
app.projections_chart_dirty = false;
|
state.chart_dirty = false;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
defer app.allocator.free(chart_result.rgb_data);
|
defer app.allocator.free(chart_result.rgb_data);
|
||||||
|
|
@ -405,7 +601,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
||||||
// Base64-encode and transmit
|
// Base64-encode and transmit
|
||||||
const base64_enc = std.base64.standard.Encoder;
|
const base64_enc = std.base64.standard.Encoder;
|
||||||
const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
|
const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
|
||||||
app.projections_chart_dirty = false;
|
state.chart_dirty = false;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
defer app.allocator.free(b64_buf);
|
defer app.allocator.free(b64_buf);
|
||||||
|
|
@ -418,21 +614,21 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
||||||
chart_result.height,
|
chart_result.height,
|
||||||
.rgb,
|
.rgb,
|
||||||
) catch {
|
) catch {
|
||||||
app.projections_chart_dirty = false;
|
state.chart_dirty = false;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
app.projections_image_id = img.id;
|
state.image_id = img.id;
|
||||||
app.projections_image_width = @intCast(chart_cols);
|
state.image_width = @intCast(chart_cols);
|
||||||
app.projections_image_height = chart_rows;
|
state.image_height = chart_rows;
|
||||||
app.projections_value_min = chart_result.value_min;
|
state.value_min = chart_result.value_min;
|
||||||
app.projections_value_max = chart_result.value_max;
|
state.value_max = chart_result.value_max;
|
||||||
app.projections_chart_dirty = false;
|
state.chart_dirty = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place the image in the cell buffer
|
// Place the image in the cell buffer
|
||||||
if (app.projections_image_id) |img_id| {
|
if (state.image_id) |img_id| {
|
||||||
const chart_row_start: usize = header_rows;
|
const chart_row_start: usize = header_rows;
|
||||||
const chart_col_start: usize = 1;
|
const chart_col_start: usize = 1;
|
||||||
const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
|
const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
|
||||||
|
|
@ -444,8 +640,8 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
||||||
.img_id = img_id,
|
.img_id = img_id,
|
||||||
.options = .{
|
.options = .{
|
||||||
.size = .{
|
.size = .{
|
||||||
.rows = app.projections_image_height,
|
.rows = state.image_height,
|
||||||
.cols = app.projections_image_width,
|
.cols = state.image_width,
|
||||||
},
|
},
|
||||||
.scale = .contain,
|
.scale = .contain,
|
||||||
},
|
},
|
||||||
|
|
@ -454,23 +650,23 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Axis labels (dollar values on the right side)
|
// Axis labels (dollar values on the right side)
|
||||||
const img_rows = app.projections_image_height;
|
const img_rows = state.image_height;
|
||||||
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.projections_image_width) + 1;
|
const label_col: usize = @as(usize, chart_col_start) + @as(usize, state.image_width) + 1;
|
||||||
const label_style = th.mutedStyle();
|
const label_style = th.mutedStyle();
|
||||||
|
|
||||||
if (label_col + 10 <= width and img_rows >= 4 and app.projections_value_max > app.projections_value_min) {
|
if (label_col + 10 <= width and img_rows >= 4 and state.value_max > state.value_min) {
|
||||||
// Label band boundaries at the right edge, in priority order:
|
// Label band boundaries at the right edge, in priority order:
|
||||||
// p10 and p90 (extremes, always kept), then p50 (median), then p25/p75 (lowest priority).
|
// p10 and p90 (extremes, always kept), then p50 (median), then p25/p75 (lowest priority).
|
||||||
const last_band = bands[bands.len - 1];
|
const last_band = bands[bands.len - 1];
|
||||||
const label_values = [_]f64{ last_band.p10, last_band.p90, last_band.p50, last_band.p25, last_band.p75 };
|
const label_values = [_]f64{ last_band.p10, last_band.p90, last_band.p50, last_band.p25, last_band.p75 };
|
||||||
const val_range = app.projections_value_max - app.projections_value_min;
|
const val_range = state.value_max - state.value_min;
|
||||||
const rows_f = @as(f64, @floatFromInt(img_rows -| 1));
|
const rows_f = @as(f64, @floatFromInt(img_rows -| 1));
|
||||||
|
|
||||||
var placed_rows: [5]usize = undefined;
|
var placed_rows: [5]usize = undefined;
|
||||||
var placed_count: usize = 0;
|
var placed_count: usize = 0;
|
||||||
|
|
||||||
for (label_values) |val| {
|
for (label_values) |val| {
|
||||||
const norm = (val - app.projections_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;
|
||||||
const row: usize = @intFromFloat(@round(row_f));
|
const row: usize = @intFromFloat(@round(row_f));
|
||||||
if (row >= height) continue;
|
if (row >= height) continue;
|
||||||
|
|
@ -536,7 +732,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render footer (terminal values + withdrawal table) below the chart
|
// Render footer (terminal values + withdrawal table) below the chart
|
||||||
const footer_start_row = header_rows + app.projections_image_height + 1; // +1 for axis row
|
const footer_start_row = header_rows + state.image_height + 1; // +1 for axis row
|
||||||
if (footer_start_row + 4 < height) {
|
if (footer_start_row + 4 < height) {
|
||||||
const footer_slice = try footer_lines.toOwnedSlice(arena);
|
const footer_slice = try footer_lines.toOwnedSlice(arena);
|
||||||
const footer_buf_start = footer_start_row * @as(usize, width);
|
const footer_buf_start = footer_start_row * @as(usize, width);
|
||||||
|
|
@ -549,7 +745,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the header section (benchmark comparison table + allocation note).
|
/// Build the header section (benchmark comparison table + allocation note).
|
||||||
fn buildHeaderSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayListUnmanaged(StyledLine), pctx: view.ProjectionContext) !void {
|
fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines: *std.ArrayListUnmanaged(StyledLine), pctx: view.ProjectionContext) !void {
|
||||||
const th = app.theme;
|
const th = app.theme;
|
||||||
const comparison = pctx.comparison;
|
const comparison = pctx.comparison;
|
||||||
const config = pctx.config;
|
const config = pctx.config;
|
||||||
|
|
@ -624,7 +820,7 @@ fn buildHeaderSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayList
|
||||||
// historical snapshot date when one is configured so the
|
// historical snapshot date when one is configured so the
|
||||||
// promoted date and earliest-retirement grid anchor on the
|
// promoted date and earliest-retirement grid anchor on the
|
||||||
// same reference point as the rest of the as-of-mode display.
|
// same reference point as the rest of the as-of-mode display.
|
||||||
const ref_date = app.projections_as_of orelse app.today;
|
const ref_date = state.as_of orelse app.today;
|
||||||
try appendAccumulationBlocks(lines, arena, th, pctx, ref_date);
|
try appendAccumulationBlocks(lines, arena, th, pctx, ref_date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -884,13 +1080,13 @@ fn appendAccumulationBlocks(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||||
const th = app.theme;
|
const th = app.theme;
|
||||||
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
|
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
|
||||||
|
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
const ctx = app.projections_ctx orelse {
|
const ctx = state.ctx orelse {
|
||||||
try lines.append(arena, .{ .text = " No projection data. Ensure portfolio is loaded.", .style = th.mutedStyle() });
|
try lines.append(arena, .{ .text = " No projection data. Ensure portfolio is loaded.", .style = th.mutedStyle() });
|
||||||
return lines.toOwnedSlice(arena);
|
return lines.toOwnedSlice(arena);
|
||||||
};
|
};
|
||||||
|
|
@ -903,8 +1099,8 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
|
||||||
// historical snapshot. Muted header note so it doesn't compete
|
// historical snapshot. Muted header note so it doesn't compete
|
||||||
// with the main content. If the user asked for a date that had no
|
// with the main content. If the user asked for a date that had no
|
||||||
// exact snapshot, a second muted line explains the auto-snap.
|
// exact snapshot, a second muted line explains the auto-snap.
|
||||||
if (app.projections_as_of) |actual| {
|
if (state.as_of) |actual| {
|
||||||
const source_label: []const u8 = if (app.projections_ctx) |c| switch (c.as_of_source) {
|
const source_label: []const u8 = if (state.ctx) |c| switch (c.as_of_source) {
|
||||||
.snapshot => "snapshot",
|
.snapshot => "snapshot",
|
||||||
.imported => "imported",
|
.imported => "imported",
|
||||||
.live => "live",
|
.live => "live",
|
||||||
|
|
@ -912,7 +1108,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
|
||||||
const header = try std.fmt.allocPrint(arena, " As-of: {f} ({s})", .{ actual, source_label });
|
const header = try std.fmt.allocPrint(arena, " As-of: {f} ({s})", .{ actual, source_label });
|
||||||
try lines.append(arena, .{ .text = header, .style = th.mutedStyle() });
|
try lines.append(arena, .{ .text = header, .style = th.mutedStyle() });
|
||||||
|
|
||||||
if (app.projections_as_of_requested) |requested| {
|
if (state.as_of_requested) |requested| {
|
||||||
if (!requested.eql(actual)) {
|
if (!requested.eql(actual)) {
|
||||||
const diff = requested.days - actual.days;
|
const diff = requested.days - actual.days;
|
||||||
const note = try std.fmt.allocPrint(
|
const note = try std.fmt.allocPrint(
|
||||||
|
|
@ -923,7 +1119,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
|
||||||
try lines.append(arena, .{ .text = note, .style = th.mutedStyle() });
|
try lines.append(arena, .{ .text = note, .style = th.mutedStyle() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (app.projections_ctx) |c| {
|
if (state.ctx) |c| {
|
||||||
if (c.as_of_source == .imported) {
|
if (c.as_of_source == .imported) {
|
||||||
try lines.append(arena, .{
|
try lines.append(arena, .{
|
||||||
.text = " (bands use today's allocation scaled to the imported liquid total)",
|
.text = " (bands use today's allocation scaled to the imported liquid total)",
|
||||||
|
|
@ -931,7 +1127,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (app.projections_overlay_actuals) {
|
if (state.overlay_actuals) {
|
||||||
const ov_note = try std.fmt.allocPrint(
|
const ov_note = try std.fmt.allocPrint(
|
||||||
arena,
|
arena,
|
||||||
" Overlay: actuals from {f} · tracks trajectory, not SWR validity",
|
" Overlay: actuals from {f} · tracks trajectory, not SWR validity",
|
||||||
|
|
@ -1014,7 +1210,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
|
||||||
// historical snapshot date when one is configured so the
|
// historical snapshot date when one is configured so the
|
||||||
// promoted date and earliest-retirement grid anchor on the
|
// promoted date and earliest-retirement grid anchor on the
|
||||||
// same reference point as the rest of the as-of-mode display.
|
// same reference point as the rest of the as-of-mode display.
|
||||||
const ref_date = app.projections_as_of orelse app.today;
|
const ref_date = state.as_of orelse app.today;
|
||||||
try appendAccumulationBlocks(&lines, arena, th, ctx, ref_date);
|
try appendAccumulationBlocks(&lines, arena, th, ctx, ref_date);
|
||||||
|
|
||||||
// Braille chart: median portfolio value over the longest horizon
|
// Braille chart: median portfolio value over the longest horizon
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue