IO-as-an-interface refactor across the codebase. The big shifts: - std.io → std.Io, std.fs → std.Io.Dir/File, std.process.Child → spawn/run. - Juicy Main: pub fn main(init: std.process.Init) gives gpa, io, arena, environ_map up front. main.zig + the build/ scripts use it directly. - Threading io through everywhere that touches the outside world (HTTP, files, stderr, sleep, terminal detection). Functions taking `io` now announce side effects at the call site — the smell is the feature. - date math takes `as_of: Date`, not `today: Date`. Caller resolves `--as-of` flag vs wall-clock at the boundary; the function operates on whatever date it's given. Every "today" parameter renamed and the as_of: ?Date + today: Date pattern collapsed. - now_s: i64 (or before_s/after_s pairs) for sub-second metadata fields like snapshot captured_at, audit cadence, formatAge/fmtTimeAgo. Also pure and testable. - legitimate Timestamp.now callers (cache TTL math, FetchResult timestamps, rate limiter, per-frame TUI "now" captures) gain `// wall-clock required: ...` comments justifying the read. Test discovery: replaced the local refAllDeclsRecursive with bare std.testing.refAllDecls(@This()). Sema-pulling main.zig's top-level decls reaches every test file transitively through the import graph; no explicit _ = @import(...) lines needed. Cleanup along the way: - Dropped DataService.allocator()/io() accessor methods; renamed the fields to drop the base_ prefix. Callers use self.allocator and self.io directly. - Dropped now-vestigial io parameters from buildSnapshot, analyzePortfolio, compareSchwabSummary, compareAccounts, buildPortfolioData, divs.display, quote.display, parsePortfolioOpts, aggregateLiveStocks, renderEarningsLines, capitalGainsIndicator, aggregateDripLots, printLotRow, portfolio.display, printSnapNote. - Dropped the unused contributions.computeAttribution date-form wrapper (only computeAttributionSpec is called). - formatAge/fmtTimeAgo take (before_s, after_s) instead of io and reading the clock internally. - parseProjectionsConfig uses an internal stack-buffer FixedBufferAllocator instead of an allocator parameter. - ThreadSafeAllocator wrappers in cache concurrency tests dropped (0.16's DebugAllocator is thread-safe by default). - analyzePortfolio bug surfaced by the rename: snapshot.zig was passing wall-clock today instead of as_of, mis-valuing cash/CDs for historical backfills. 83 new unit tests added due to removal of IO, bringing coverage from 58% -> 64%
342 lines
12 KiB
Zig
342 lines
12 KiB
Zig
//! 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(
|
|
io: std.Io,
|
|
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(io, 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(std.testing.io, 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(std.testing.io, alloc, &bands, 200, 100, th);
|
|
try std.testing.expectError(error.InsufficientData, result);
|
|
}
|