add graph and set up scrolling

This commit is contained in:
Emil Lerch 2026-04-28 11:46:17 -07:00
parent bbdc46702f
commit 6ea0a58949
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 798 additions and 18 deletions

View file

@ -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(), .{});
}

View file

@ -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 {

View file

@ -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);
}

View file

@ -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(&note_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;