add graph and set up scrolling
This commit is contained in:
parent
bbdc46702f
commit
6ea0a58949
4 changed files with 798 additions and 18 deletions
45
src/tui.zig
45
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(), .{});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
341
src/tui/projection_chart.zig
Normal file
341
src/tui/projection_chart.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue