//! 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); }