From 6ea0a589494f504ae47b9b73e8e9168ef6603b85 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 28 Apr 2026 11:46:17 -0700 Subject: [PATCH] add graph and set up scrolling --- src/tui.zig | 45 +++- src/tui/keybinds.zig | 2 + src/tui/projection_chart.zig | 341 ++++++++++++++++++++++++++++ src/tui/projections_tab.zig | 428 +++++++++++++++++++++++++++++++++-- 4 files changed, 798 insertions(+), 18 deletions(-) create mode 100644 src/tui/projection_chart.zig diff --git a/src/tui.zig b/src/tui.zig index 1b11bf7..407012e 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -388,6 +388,13 @@ pub const App = struct { 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_value_min: f64 = 0, + projections_value_max: f64 = 0, // Default to `.liquid` — that's the metric most worth watching // day-to-day. Illiquid barely changes, net_worth is dominated by // liquid anyway, so "show me liquid" is the headline view. @@ -1105,6 +1112,14 @@ pub const App = struct { return ctx.consumeAndRedraw(); } }, + .toggle_chart => { + if (self.active_tab == .projections) { + self.projections_chart_visible = !self.projections_chart_visible; + self.projections_chart_dirty = true; + self.scroll_offset = 0; + return ctx.consumeAndRedraw(); + } + }, } } @@ -1672,12 +1687,28 @@ pub const App = struct { switch (self.active_tab) { .portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height), .quote => try self.drawQuoteContent(ctx, buf, width, height), - .performance => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildPerfStyledLines(ctx.arena)), + .performance => { + const lines = try self.buildPerfStyledLines(ctx.arena); + 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..]); + }, .options => try self.drawOptionsContent(ctx.arena, buf, width, height), - .earnings => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildEarningsStyledLines(ctx.arena)), - .analysis => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildAnalysisStyledLines(ctx.arena)), - .history => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHistoryStyledLines(ctx.arena)), - .projections => try self.drawStyledContent(ctx.arena, buf, width, height, try projections_tab.buildStyledLines(self, ctx.arena)), + .earnings => { + const lines = try self.buildEarningsStyledLines(ctx.arena); + 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..]); + }, + .analysis => { + const lines = try self.buildAnalysisStyledLines(ctx.arena); + 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..]); + }, + .history => { + const lines = try self.buildHistoryStyledLines(ctx.arena); + 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..]); + }, + .projections => try projections_tab.drawContent(self, ctx, buf, width, height), } } @@ -2257,6 +2288,10 @@ pub fn run( vx_app.vx.freeImage(vx_app.tty.writer(), id); app_inst.chart.image_id = null; } + if (app_inst.projections_image_id) |id| { + vx_app.vx.freeImage(vx_app.tty.writer(), id); + app_inst.projections_image_id = null; + } } try vx_app.run(app_inst.widget(), .{}); } diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index d1c4542..a967215 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -47,6 +47,7 @@ pub const Action = enum { sort_col_prev, sort_reverse, account_filter, + toggle_chart, }; pub const KeyCombo = struct { @@ -134,6 +135,7 @@ const default_bindings = [_]Binding{ .{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } }, .{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } }, .{ .action = .account_filter, .key = .{ .codepoint = 'a' } }, + .{ .action = .toggle_chart, .key = .{ .codepoint = 'v' } }, }; pub fn defaults() KeyMap { diff --git a/src/tui/projection_chart.zig b/src/tui/projection_chart.zig new file mode 100644 index 0000000..62faa54 --- /dev/null +++ b/src/tui/projection_chart.zig @@ -0,0 +1,341 @@ +//! Projection chart renderer using z2d. +//! Renders percentile bands (p10-p90, p25-p75) with a median line to raw RGB +//! pixel data suitable for Kitty graphics protocol transmission. +//! +//! Visual layers (bottom to top): +//! - Background +//! - Horizontal grid lines +//! - p10-p90 band fill (faint) +//! - p25-p75 band fill (medium) +//! - Median (p50) line (solid) +//! - Panel border + +const std = @import("std"); +const z2d = @import("z2d"); +const theme = @import("theme.zig"); +const projections = @import("../analytics/projections.zig"); + +const Surface = z2d.Surface; +const Context = z2d.Context; +const Pixel = z2d.Pixel; + +/// Margins in pixels. +const margin_left: f64 = 4; +const margin_right: f64 = 4; +const margin_top: f64 = 4; +const margin_bottom: f64 = 4; + +/// Projection chart render result. +pub const ProjectionChartResult = struct { + /// Raw RGB pixel data (3 bytes per pixel, row-major). + rgb_data: []const u8, + width: u16, + height: u16, + /// Value range for external label rendering. + value_min: f64, + value_max: f64, +}; + +/// Render a projection percentile band chart to raw RGB pixel data. +/// Draws p10-p90 outer band, p25-p75 inner band, and p50 median line. +/// +/// `bands` is the array of YearPercentiles (year 0 through horizon). +/// The returned rgb_data is allocated with `alloc` and must be freed by caller. +pub fn renderProjectionChart( + alloc: std.mem.Allocator, + bands: []const projections.YearPercentiles, + width_px: u32, + height_px: u32, + th: theme.Theme, +) !ProjectionChartResult { + if (bands.len < 2) return error.InsufficientData; + + const w: i32 = @intCast(width_px); + const h: i32 = @intCast(height_px); + var sfc = try Surface.init(.image_surface_rgb, alloc, w, h); + defer sfc.deinit(alloc); + + var ctx = Context.init(alloc, &sfc); + defer ctx.deinit(); + + ctx.setAntiAliasingMode(.none); + ctx.setOperator(.src); + + const bg = th.bg; + const fwidth: f64 = @floatFromInt(width_px); + const fheight: f64 = @floatFromInt(height_px); + + // Background + ctx.setSourceToPixel(opaqueColor(bg)); + ctx.resetPath(); + try ctx.moveTo(0, 0); + try ctx.lineTo(fwidth, 0); + try ctx.lineTo(fwidth, fheight); + try ctx.lineTo(0, fheight); + try ctx.closePath(); + try ctx.fill(); + + // Chart area + const chart_left = margin_left; + const chart_right = fwidth - margin_right; + const chart_w = chart_right - chart_left; + const chart_top = margin_top; + const chart_bottom = fheight - margin_bottom; + + // Compute value range from all bands + var value_min: f64 = bands[0].p10; + var value_max: f64 = bands[0].p90; + for (bands) |bp| { + if (bp.p10 < value_min) value_min = bp.p10; + if (bp.p90 > value_max) value_max = bp.p90; + } + // Add 5% padding + const pad = (value_max - value_min) * 0.05; + value_min -= pad; + value_max += pad; + if (value_min < 0) value_min = 0; + + // X step (one point per year) + const x_step = chart_w / @as(f64, @floatFromInt(bands.len - 1)); + + // ── Grid lines ─────────────────────────────────────────────── + const grid_color = blendColor(th.text_muted, 40, bg); + try drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, grid_color); + + // ── p10-p90 outer band fill ────────────────────────────────── + { + const band_color = blendColor(th.accent, 20, bg); + ctx.setSourceToPixel(band_color); + ctx.resetPath(); + + // Forward along p90 (upper) + for (bands, 0..) |bp, i| { + const x = chart_left + @as(f64, @floatFromInt(i)) * x_step; + const y = mapY(bp.p90, value_min, value_max, chart_top, chart_bottom); + if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); + } + // Backward along p10 (lower) + var i: usize = bands.len; + while (i > 0) { + i -= 1; + const x = chart_left + @as(f64, @floatFromInt(i)) * x_step; + const y = mapY(bands[i].p10, value_min, value_max, chart_top, chart_bottom); + try ctx.lineTo(x, y); + } + try ctx.closePath(); + try ctx.fill(); + } + + // ── p25-p75 inner band fill ────────────────────────────────── + { + const band_color = blendColor(th.accent, 40, bg); + ctx.setSourceToPixel(band_color); + ctx.resetPath(); + + // Forward along p75 (upper) + for (bands, 0..) |bp, i| { + const x = chart_left + @as(f64, @floatFromInt(i)) * x_step; + const y = mapY(bp.p75, value_min, value_max, chart_top, chart_bottom); + if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); + } + // Backward along p25 (lower) + var i: usize = bands.len; + while (i > 0) { + i -= 1; + const x = chart_left + @as(f64, @floatFromInt(i)) * x_step; + const y = mapY(bands[i].p25, value_min, value_max, chart_top, chart_bottom); + try ctx.lineTo(x, y); + } + try ctx.closePath(); + try ctx.fill(); + } + + // ── Median (p50) line ──────────────────────────────────────── + { + const line_color = opaqueColor(th.accent); + ctx.setSourceToPixel(line_color); + ctx.setLineWidth(2.0); + ctx.resetPath(); + for (bands, 0..) |bp, i| { + const x = chart_left + @as(f64, @floatFromInt(i)) * x_step; + const y = mapY(bp.p50, value_min, value_max, chart_top, chart_bottom); + if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); + } + try ctx.stroke(); + } + + // ── p10 and p90 boundary lines (thin, muted) ───────────────── + { + const line_color = blendColor(th.text_muted, 80, bg); + ctx.setSourceToPixel(line_color); + ctx.setLineWidth(1.0); + ctx.resetPath(); + for (bands, 0..) |bp, i| { + const x = chart_left + @as(f64, @floatFromInt(i)) * x_step; + const y = mapY(bp.p90, value_min, value_max, chart_top, chart_bottom); + if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); + } + try ctx.stroke(); + + ctx.resetPath(); + for (bands, 0..) |bp, i| { + const x = chart_left + @as(f64, @floatFromInt(i)) * x_step; + const y = mapY(bp.p10, value_min, value_max, chart_top, chart_bottom); + if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); + } + try ctx.stroke(); + } + + // ── Zero line (if visible) ─────────────────────────────────── + if (value_min <= 0 and value_max > 0) { + const zero_y = mapY(0, value_min, value_max, chart_top, chart_bottom); + const zero_color = blendColor(th.negative, 120, bg); + try drawHLine(&ctx, chart_left, chart_right, zero_y, zero_color, 1.0); + } + + // ── Panel border ───────────────────────────────────────────── + { + const border_color = blendColor(th.border, 80, bg); + try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, border_color, 1.0); + } + + // Extract raw RGB pixel data + const rgb_buf = switch (sfc) { + .image_surface_rgb => |s| s.buf, + else => unreachable, + }; + const pixel_count = rgb_buf.len; + const raw = try alloc.alloc(u8, pixel_count * 3); + for (rgb_buf, 0..) |px, pi| { + raw[pi * 3 + 0] = px.r; + raw[pi * 3 + 1] = px.g; + raw[pi * 3 + 2] = px.b; + } + + return .{ + .rgb_data = raw, + .width = @intCast(width_px), + .height = @intCast(height_px), + .value_min = value_min, + .value_max = value_max, + }; +} + +// ── Drawing helpers ─────────────────────────────────────────────────── + +fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 { + if (max_val == min_val) return (top_px + bottom_px) / 2; + const norm = (value - min_val) / (max_val - min_val); + return bottom_px - norm * (bottom_px - top_px); +} + +fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel { + const a = @as(f64, @floatFromInt(alpha)) / 255.0; + const inv_a = 1.0 - a; + return .{ .rgb = .{ + .r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a), + .g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a), + .b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a), + } }; +} + +fn opaqueColor(c: [3]u8) Pixel { + return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } }; +} + +fn drawHorizontalGridLines( + ctx: *Context, + left: f64, + right: f64, + top: f64, + bottom: f64, + n_lines: usize, + col: Pixel, +) !void { + ctx.setSourceToPixel(col); + ctx.setLineWidth(0.5); + for (1..n_lines) |i| { + const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines)); + const y = top + frac * (bottom - top); + ctx.resetPath(); + try ctx.moveTo(left, y); + try ctx.lineTo(right, y); + try ctx.stroke(); + } + ctx.setLineWidth(2.0); +} + +fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, line_w: f64) !void { + ctx.setSourceToPixel(col); + ctx.setLineWidth(line_w); + ctx.resetPath(); + try ctx.moveTo(x1, y); + try ctx.lineTo(x2, y); + try ctx.stroke(); + ctx.setLineWidth(2.0); +} + +fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, line_w: f64) !void { + ctx.setSourceToPixel(col); + ctx.setLineWidth(line_w); + ctx.resetPath(); + try ctx.moveTo(x1, y1); + try ctx.lineTo(x2, y1); + try ctx.lineTo(x2, y2); + try ctx.lineTo(x1, y2); + try ctx.closePath(); + try ctx.stroke(); + ctx.setLineWidth(2.0); +} + +// ── Tests ───────────────────────────────────────────────────────────── + +test "mapY maps value to pixel coordinate" { + try std.testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500)); + try std.testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500)); + try std.testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500)); + try std.testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500)); +} + +test "blendColor alpha blending" { + const white = [3]u8{ 255, 255, 255 }; + const black = [3]u8{ 0, 0, 0 }; + + const full = blendColor(white, 255, black); + try std.testing.expectEqual(@as(u8, 255), full.rgb.r); + + const zero = blendColor(white, 0, black); + try std.testing.expectEqual(@as(u8, 0), zero.rgb.r); + + const half = blendColor(white, 128, black); + try std.testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129); +} + +test "renderProjectionChart produces valid output" { + const alloc = std.testing.allocator; + const bands = [_]projections.YearPercentiles{ + .{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 }, + .{ .year = 10, .p10 = 5000000, .p25 = 8000000, .p50 = 12000000, .p75 = 18000000, .p90 = 25000000 }, + .{ .year = 20, .p10 = 3000000, .p25 = 9000000, .p50 = 18000000, .p75 = 30000000, .p90 = 50000000 }, + }; + + const th = @import("theme.zig").default_theme; + const result = try renderProjectionChart(alloc, &bands, 200, 100, th); + defer alloc.free(result.rgb_data); + + try std.testing.expectEqual(@as(u16, 200), result.width); + try std.testing.expectEqual(@as(u16, 100), result.height); + try std.testing.expectEqual(@as(usize, 200 * 100 * 3), result.rgb_data.len); + try std.testing.expect(result.value_max > result.value_min); +} + +test "renderProjectionChart insufficient data" { + const alloc = std.testing.allocator; + const bands = [_]projections.YearPercentiles{ + .{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 }, + }; + + const th = @import("theme.zig").default_theme; + const result = renderProjectionChart(alloc, &bands, 200, 100, th); + try std.testing.expectError(error.InsufficientData, result); +} diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 93defd7..26b112e 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -17,6 +17,8 @@ const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); +const chart = @import("chart.zig"); +const projection_chart = @import("projection_chart.zig"); const projections = @import("../analytics/projections.zig"); const benchmark = @import("../analytics/benchmark.zig"); const performance = @import("../analytics/performance.zig"); @@ -69,10 +71,410 @@ pub fn freeLoaded(app: *App) void { app.allocator.free(ctx.data.bands); } app.projections_ctx = null; + // Mark projection chart as dirty so it re-renders on next draw + app.projections_chart_dirty = true; } // ── Rendering ───────────────────────────────────────────────── +pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void { + const arena = ctx.arena; + + // Determine whether to use Kitty graphics + const use_kitty = switch (app.chart.config.mode) { + .braille => false, + .kitty => true, + .auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false, + }; + + // Need bands data for the chart + const has_bands = if (app.projections_ctx) |pctx| blk: { + const horizons = pctx.config.getHorizons(); + if (horizons.len == 0) break :blk false; + const last_idx = horizons.len - 1; + if (pctx.data.bands[last_idx]) |bands| { + break :blk bands.len >= 2; + } + break :blk false; + } else false; + + if (use_kitty and has_bands and app.projections_chart_visible) { + drawWithKittyChart(app, ctx, buf, width, height) catch { + try drawWithScroll(app, arena, buf, width, height); + }; + } else { + try drawWithScroll(app, arena, buf, width, height); + } +} + +/// Render styled lines with scroll_offset applied. +fn drawWithScroll(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { + const all_lines = try buildStyledLines(app, arena); + 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..]); +} + +/// 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 { + const arena = ctx.arena; + const th = app.theme; + const pctx = app.projections_ctx orelse return; + 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; + + // Build text header (benchmark comparison + allocation note) + var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty; + try buildHeaderSection(app, arena, &header_lines, pctx); + + // Chart title + try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try header_lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " Portfolio Projection ({d}-Year, percentile bands at 99% withdrawal)", .{horizons[last_idx]}), + .style = th.headerStyle(), + }); + try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // Pre-build footer to compute its line count for adaptive chart sizing + var footer_lines: std.ArrayListUnmanaged(StyledLine) = .empty; + try buildFooterSection(app, arena, &footer_lines, pctx); + const footer_line_count: u16 = @intCast(@min(footer_lines.items.len, height)); + + // Draw header into buffer + const header_slice = try header_lines.toOwnedSlice(arena); + try app.drawStyledContent(arena, buf, width, height, header_slice); + + // Calculate chart area — adaptive: leave room for footer + 1 row for year axis + const header_rows: u16 = @intCast(@min(header_slice.len, height)); + const footer_reserve = footer_line_count + 1; // +1 for year axis row + const chart_rows = height -| header_rows -| footer_reserve; + if (chart_rows < 6) { + // Not enough space for chart — fall back to text-only with scroll + try drawWithScroll(app, arena, buf, width, height); + return; + } + + // Compute pixel dimensions + const cell_w: u32 = if (ctx.cell_size.width > 0) ctx.cell_size.width else 8; + const cell_h: u32 = if (ctx.cell_size.height > 0) ctx.cell_size.height else 16; + const label_cols: u16 = 12; // columns for axis labels on the right + const chart_cols = width -| 2 -| label_cols; + if (chart_cols == 0) return; + const px_w: u32 = @as(u32, chart_cols) * cell_w; + const px_h: u32 = @as(u32, chart_rows) * cell_h; + + if (px_w < 100 or px_h < 100) return; + const capped_w = @min(px_w, app.chart.config.max_width); + const capped_h = @min(px_h, app.chart.config.max_height); + + // Render or reuse cached image + if (app.projections_chart_dirty) { + // Free old image + if (app.projections_image_id) |old_id| { + if (app.vx_app) |va| { + va.vx.freeImage(va.tty.writer(), old_id); + } + app.projections_image_id = null; + } + + if (app.vx_app) |va| { + const chart_result = projection_chart.renderProjectionChart( + app.allocator, + bands, + capped_w, + capped_h, + th, + ) catch { + app.projections_chart_dirty = false; + return; + }; + defer app.allocator.free(chart_result.rgb_data); + + // Base64-encode and transmit + const base64_enc = std.base64.standard.Encoder; + const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch { + app.projections_chart_dirty = false; + return; + }; + defer app.allocator.free(b64_buf); + const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data); + + const img = va.vx.transmitPreEncodedImage( + va.tty.writer(), + encoded, + chart_result.width, + chart_result.height, + .rgb, + ) catch { + app.projections_chart_dirty = false; + return; + }; + + app.projections_image_id = img.id; + app.projections_image_width = @intCast(chart_cols); + app.projections_image_height = chart_rows; + app.projections_value_min = chart_result.value_min; + app.projections_value_max = chart_result.value_max; + app.projections_chart_dirty = false; + } + } + + // Place the image in the cell buffer + if (app.projections_image_id) |img_id| { + const chart_row_start: usize = header_rows; + const chart_col_start: usize = 1; + const buf_idx = chart_row_start * @as(usize, width) + chart_col_start; + if (buf_idx < buf.len) { + buf[buf_idx] = .{ + .char = .{ .grapheme = " " }, + .style = th.contentStyle(), + .image = .{ + .img_id = img_id, + .options = .{ + .size = .{ + .rows = app.projections_image_height, + .cols = app.projections_image_width, + }, + .scale = .contain, + }, + }, + }; + } + + // Axis labels (dollar values on the right side) + const img_rows = app.projections_image_height; + const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.projections_image_width) + 1; + const label_style = th.mutedStyle(); + + if (label_col + 10 <= width and img_rows >= 4 and app.projections_value_max > app.projections_value_min) { + // Label band boundaries at the right edge, in priority order: + // p10 and p90 (extremes, always kept), then p50 (median), then p25/p75 (lowest priority). + 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 val_range = app.projections_value_max - app.projections_value_min; + const rows_f = @as(f64, @floatFromInt(img_rows -| 1)); + + var placed_rows: [5]usize = undefined; + var placed_count: usize = 0; + + for (label_values) |val| { + const norm = (val - app.projections_value_min) / val_range; + const row_f = @as(f64, @floatFromInt(chart_row_start)) + (1.0 - norm) * rows_f; + const row: usize = @intFromFloat(@round(row_f)); + if (row >= height) continue; + + // Skip if this label would overlap any already-placed label + var overlaps = false; + for (placed_rows[0..placed_count]) |prev_row| { + const diff = if (row >= prev_row) row - prev_row else prev_row - row; + if (diff <= 1) { + overlaps = true; + break; + } + } + if (overlaps) continue; + + // Format as whole dollars (no decimals) + var lbl_buf: [16]u8 = undefined; + const lbl_full = fmt.fmtMoneyAbs(&lbl_buf, @round(val)); + const lbl = if (std.mem.lastIndexOfScalar(u8, lbl_full, '.')) |dot| + lbl_full[0..dot] + else + lbl_full; + + const start_idx = row * @as(usize, width) + label_col; + for (lbl, 0..) |ch, ci| { + const idx = start_idx + ci; + if (idx < buf.len and label_col + ci < width) { + buf[idx] = .{ + .char = .{ .grapheme = tui.glyph(ch) }, + .style = label_style, + }; + } + } + placed_rows[placed_count] = row; + placed_count += 1; + } + + // Year axis: "Now" on left edge, "{horizon}yr" on right edge of chart + const axis_row: usize = chart_row_start + @as(usize, img_rows); + if (axis_row < height) { + const axis_base = axis_row * @as(usize, width); + // "Now" at left + const now_label = "Now"; + for (now_label, 0..) |ch, ci| { + const idx = axis_base + chart_col_start + ci; + if (idx < buf.len) { + buf[idx] = .{ + .char = .{ .grapheme = tui.glyph(ch) }, + .style = label_style, + }; + } + } + // "{horizon}yr" at right edge of chart area + var yr_buf: [8]u8 = undefined; + const yr_label = std.fmt.bufPrint(&yr_buf, "{d}yr", .{horizons[last_idx]}) catch "??yr"; + const yr_start = chart_col_start + @as(usize, chart_cols) -| yr_label.len; + for (yr_label, 0..) |ch, ci| { + const idx = axis_base + yr_start + ci; + if (idx < buf.len) { + buf[idx] = .{ + .char = .{ .grapheme = tui.glyph(ch) }, + .style = label_style, + }; + } + } + } + } + + // Render footer (terminal values + withdrawal table) below the chart + const footer_start_row = header_rows + app.projections_image_height + 1; // +1 for axis row + if (footer_start_row + 4 < height) { + const footer_slice = try footer_lines.toOwnedSlice(arena); + const footer_buf_start = footer_start_row * @as(usize, width); + const remaining_height = height - @as(u16, @intCast(footer_start_row)); + if (footer_buf_start < buf.len) { + try app.drawStyledContent(arena, buf[footer_buf_start..], width, remaining_height, footer_slice); + } + } + } +} + +/// 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 { + const th = app.theme; + const comparison = pctx.comparison; + const config = pctx.config; + const stock_pct = pctx.stock_pct; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Benchmark Comparison", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // Column headers + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s: <30}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}", .{ + "", "1 Year", "3 Year", "5 Year", "10 Year", "Week", + }), + .style = th.headerStyle(), + }); + + // Return rows + var spy_bufs: [5][16]u8 = undefined; + var spy_label_buf: [32]u8 = undefined; + const spy_row = view.buildReturnRow( + view.fmtBenchmarkLabel(&spy_label_buf, "SPY", stock_pct * 100), + comparison.stock_returns, + &spy_bufs, + false, + ); + try appendReturnRow(lines, arena, th, spy_row); + + var agg_bufs: [5][16]u8 = undefined; + var agg_label_buf: [32]u8 = undefined; + const agg_row = view.buildReturnRow( + view.fmtBenchmarkLabel(&agg_label_buf, "AGG", pctx.bond_pct * 100), + comparison.bond_returns, + &agg_bufs, + false, + ); + try appendReturnRow(lines, arena, th, agg_row); + + var bench_bufs: [5][16]u8 = undefined; + const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true); + try appendReturnRow(lines, arena, th, bench_row); + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + var port_bufs: [5][16]u8 = undefined; + const port_row = view.buildReturnRow("Your Portfolio", comparison.portfolio_returns, &port_bufs, true); + try appendReturnRow(lines, arena, th, port_row); + + // Conservative estimate + { + var buf: [16]u8 = undefined; + const cell = view.fmtReturnCell(&buf, comparison.conservative_return); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s: <30}{s: >8}", .{ "Conservative estimate", cell.text }), + .style = th.mutedStyle(), + }); + } + + // Target allocation note + { + var note_buf: [128]u8 = undefined; + if (view.fmtAllocationNote(¬e_buf, config.target_stock_pct, stock_pct)) |note| { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{note.text}), + .style = th.styleFor(note.style), + }); + } + } +} + +/// Build the footer section (terminal values + safe withdrawal table). +fn buildFooterSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayListUnmanaged(StyledLine), pctx: view.ProjectionContext) !void { + const th = app.theme; + const config = pctx.config; + const horizons = config.getHorizons(); + + // Terminal portfolio value + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ + .text = " Terminal Portfolio Value (nominal, at 99% withdrawal rate)", + .style = th.headerStyle(), + }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.terminal_col_width)}), + .style = th.headerStyle(), + }); + + { + const all_bands = pctx.data.bands; + const p_labels = [_][]const u8{ "Pessimistic (p10)", "Median (p50)", "Optimistic (p90)" }; + const p_styles = [_]view.StyleIntent{ .muted, .normal, .muted }; + for (p_labels, p_styles, 0..) |plabel, pstyle, pi| { + const row = try view.buildPercentileRow(arena, plabel, pi, all_bands, pstyle); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{row.text}), + .style = th.styleFor(row.style), + }); + } + } + + // Safe withdrawal table + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ + .text = " Safe Withdrawal (FIRECalc historical simulation)", + .style = th.headerStyle(), + }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.withdrawal_col_width)}), + .style = th.headerStyle(), + }); + + const cached_wr = pctx.data.withdrawals; + const confidence_levels = config.getConfidenceLevels(); + for (confidence_levels, 0..) |conf, ci| { + const rows = try view.buildWithdrawalRows(arena, conf, horizons, cached_wr, ci); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{rows.amount.text}), + .style = th.contentStyle(), + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{rows.rate.text}), + .style = th.mutedStyle(), + }); + } +} + pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { const th = app.theme; var lines: std.ArrayListUnmanaged(StyledLine) = .empty; @@ -189,14 +591,14 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine const chart_height: usize = 12; var br = fmt.computeBrailleChart(arena, candles, chart_width, chart_height, th.positive, th.negative) catch null; - if (br) |*chart| { + if (br) |*br_chart| { const bg = th.bg; const muted_fg = theme.Theme.vcolor(th.text_muted); const bg_v = theme.Theme.vcolor(bg); - for (0..chart.chart_height) |row| { - const graphemes = try arena.alloc([]const u8, chart.n_cols + 20); - const styles = try arena.alloc(vaxis.Style, chart.n_cols + 20); + for (0..br_chart.chart_height) |row| { + const graphemes = try arena.alloc([]const u8, br_chart.n_cols + 20); + const styles = try arena.alloc(vaxis.Style, br_chart.n_cols + 20); var gpos: usize = 0; // 2 leading spaces @@ -208,11 +610,11 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine gpos += 1; // Chart columns - for (0..chart.n_cols) |col| { - const pat = chart.pattern(row, col); + for (0..br_chart.n_cols) |col| { + const pat = br_chart.pattern(row, col); graphemes[gpos] = fmt.brailleGlyph(pat); if (pat != 0) { - styles[gpos] = .{ .fg = theme.Theme.vcolor(chart.col_colors[col]), .bg = bg_v }; + styles[gpos] = .{ .fg = theme.Theme.vcolor(br_chart.col_colors[col]), .bg = bg_v }; } else { styles[gpos] = .{ .fg = bg_v, .bg = bg_v }; } @@ -220,8 +622,8 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine } // Right-side price labels - if (row == 0 or row == chart.chart_height - 1) { - const lbl = if (row == 0) chart.maxLabel() else chart.minLabel(); + if (row == 0 or row == br_chart.chart_height - 1) { + const lbl = if (row == 0) br_chart.maxLabel() else br_chart.minLabel(); const lbl_full = try std.fmt.allocPrint(arena, " {s}", .{lbl}); for (lbl_full) |ch| { if (gpos < graphemes.len) { @@ -242,8 +644,8 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine // Year axis: "Now" on left, "{horizon}yr" on right { - const axis_graphemes = try arena.alloc([]const u8, chart.n_cols + 20); - const axis_styles = try arena.alloc(vaxis.Style, chart.n_cols + 20); + const axis_graphemes = try arena.alloc([]const u8, br_chart.n_cols + 20); + const axis_styles = try arena.alloc(vaxis.Style, br_chart.n_cols + 20); const muted_style = vaxis.Style{ .fg = muted_fg, .bg = bg_v }; var apos: usize = 0; @@ -256,8 +658,8 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine // Padding to right-align the end label const end_label = try std.fmt.allocPrint(arena, "{d}yr", .{horizons[last_idx]}); - const pad = if (chart.n_cols + 2 > 3 + end_label.len) chart.n_cols + 2 - 3 - end_label.len else 0; - for (0..pad) |_| { + const n_pad = if (br_chart.n_cols + 2 > 3 + end_label.len) br_chart.n_cols + 2 - 3 - end_label.len else 0; + for (0..n_pad) |_| { axis_graphemes[apos] = " "; axis_styles[apos] = muted_style; apos += 1;