//! 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 //! - "Today" vertical reference line (when overlay is active) //! - p10-p90 band fill (faint) //! - p25-p75 band fill (medium) //! - p10/p90 boundary lines //! - Median (p50) line (solid) //! - Zero line (if visible) //! - Actuals overlay line (when present) //! - 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; /// Single (year-offset, liquid-value) point on the actuals overlay. /// Mirrors `views/projections.ActualsPoint` but kept duplicated here /// so the chart module doesn't depend on the views layer. pub const ActualsPoint = struct { years_from_as_of: f64, liquid: f64, }; /// Optional actuals-overlay input. When non-null, the chart draws: /// - A thin vertical "today" reference line at `today_years`. /// - A connected line through `points` (cyan), wider than 1px so /// it reads against the bands. /// Y-range is expanded to include any actuals values that exceed /// the band envelope, so the line never clips off-chart. pub const ActualsOverlay = struct { points: []const ActualsPoint, today_years: f64, }; /// 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, }; /// Owned by the caller — call `result.deinit(alloc)` after using it. /// Used as the shared mid-stage between RGB extraction (kitty) and /// PNG export (`--export-chart`). See `renderToSurface`. pub const RenderedProjection = struct { surface: Surface, width: u16, height: u16, value_min: f64, value_max: f64, pub fn deinit(self: *RenderedProjection, alloc: std.mem.Allocator) void { self.surface.deinit(alloc); self.* = undefined; } /// Extract a flat []u8 of R,G,B triplets from the surface buffer. /// Caller owns the returned slice. The surface is left intact. pub fn extractRgb(self: *const RenderedProjection, alloc: std.mem.Allocator) ![]u8 { const rgb_buf = switch (self.surface) { .image_surface_rgb => |s| s.buf, else => unreachable, }; const raw = try alloc.alloc(u8, rgb_buf.len * 3); for (rgb_buf, 0..) |px, i| { raw[i * 3 + 0] = px.r; raw[i * 3 + 1] = px.g; raw[i * 3 + 2] = px.b; } return raw; } }; /// Render a projection percentile band chart into a `Surface` and /// return both. Caller owns the result and must call `deinit`. /// /// Two consumers today: /// - `renderProjectionChart` wraps this for the TUI's kitty /// graphics path (extracts RGB, frees surface). /// - `--export-chart` (CLI) wraps this for PNG export via /// `z2d.png_exporter.writeToPNGFile`. pub fn renderToSurface( io: std.Io, alloc: std.mem.Allocator, bands: []const projections.YearPercentiles, width_px: u32, height_px: u32, th: theme.Theme, actuals: ?ActualsOverlay, ) !RenderedProjection { 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); errdefer 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; } // Expand to include any actuals that punch through the band // envelope. Without this, a portfolio that out- or under-performed // the model would clip off-chart. if (actuals) |ov| { for (ov.points) |p| { if (p.liquid < value_min) value_min = p.liquid; if (p.liquid > value_max) value_max = p.liquid; } } // 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); // ── "Today" vertical reference line (overlay only) ─────────── // // When the actuals overlay is active, draw a thin vertical line // at `today_years` along the x-axis. This visually separates the // realized past (left of the line) from the projected future // (right of the line). Drawn before bands so it sits behind the // data — a quiet reference, not a focal point. if (actuals) |ov| { const horizon_years: f64 = @floatFromInt(bands.len - 1); if (horizon_years > 0 and ov.today_years >= 0 and ov.today_years <= horizon_years) { const today_x = chart_left + (ov.today_years / horizon_years) * chart_w; const today_color = blendColor(th.text_muted, 100, bg); try drawVLine(&ctx, today_x, chart_top, chart_bottom, today_color, 1.0); } } // ── 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); } // ── Actuals overlay line ───────────────────────────────────── // // Drawn after all band-related layers (and the zero line) so the // realized trajectory sits on top of the projection envelope. // Cyan (`th.info`) keeps it visually distinct from the purple // band/median palette. Slightly thinner than the median (1.5 vs // 2.0) so the median stays the anchor of the projection. if (actuals) |ov| { if (ov.points.len >= 2) { const horizon_years: f64 = @floatFromInt(bands.len - 1); const line_color = opaqueColor(th.info); ctx.setSourceToPixel(line_color); ctx.setLineWidth(1.5); ctx.resetPath(); for (ov.points, 0..) |p, i| { // Clamp to chart bounds — overlay points should // always be in [0, today_years] which is <= horizon, // but defending against bad input is cheap. const yr = if (horizon_years > 0) @max(0.0, @min(p.years_from_as_of, horizon_years)) else 0.0; const x = chart_left + if (horizon_years > 0) (yr / horizon_years) * chart_w else 0.0; const y = mapY(p.liquid, 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(); } } // ── 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); } return .{ .surface = sfc, .width = @intCast(width_px), .height = @intCast(height_px), .value_min = value_min, .value_max = value_max, }; } /// 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). /// `actuals` is an optional realized-trajectory overlay. /// 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, actuals: ?ActualsOverlay, ) !ProjectionChartResult { var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals); defer rendered.deinit(alloc); const raw = try rendered.extractRgb(alloc); return .{ .rgb_data = raw, .width = rendered.width, .height = rendered.height, .value_min = rendered.value_min, .value_max = rendered.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 drawVLine(ctx: *Context, x: f64, y1: f64, y2: f64, col: Pixel, line_w: f64) !void { ctx.setSourceToPixel(col); ctx.setLineWidth(line_w); ctx.resetPath(); try ctx.moveTo(x, y1); try ctx.lineTo(x, y2); 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, null); 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, null); try std.testing.expectError(error.InsufficientData, result); } test "renderProjectionChart with overlay 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 points = [_]ActualsPoint{ .{ .years_from_as_of = 0.0, .liquid = 8000000 }, .{ .years_from_as_of = 0.5, .liquid = 8500000 }, .{ .years_from_as_of = 1.0, .liquid = 9200000 }, }; const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 }; const th = @import("theme.zig").default_theme; const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay); 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.expect(result.value_max > result.value_min); } test "renderProjectionChart overlay expands y-range when actuals exceed bands" { const alloc = std.testing.allocator; // Bands top out at 25M 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 }, }; // Actuals punch through the top band at 50M const points = [_]ActualsPoint{ .{ .years_from_as_of = 0.0, .liquid = 8000000 }, .{ .years_from_as_of = 1.0, .liquid = 50000000 }, }; const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 }; const th = @import("theme.zig").default_theme; const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay); defer alloc.free(result.rgb_data); // Without expansion, value_max would be ~25M (band p90 + 5%). // With expansion to include actuals, it must be >= 50M. try std.testing.expect(result.value_max >= 50_000_000); } test "renderProjectionChart overlay with no points renders without crash" { 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 }, }; const overlay: ActualsOverlay = .{ .points = &.{}, .today_years = 0.5 }; const th = @import("theme.zig").default_theme; const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay); defer alloc.free(result.rgb_data); try std.testing.expect(result.rgb_data.len > 0); } // ── renderToSurface direct tests ────────────────────────────────────── // // Exercise the surface-builder seam introduced for --export-chart. // Verifies the surface is the right type/dimensions and that // extractRgb round-trips correctly. test "renderToSurface returns a populated RGB surface at requested dimensions" { const alloc = std.testing.allocator; const bands = [_]projections.YearPercentiles{ .{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }, .{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 }, }; const th = @import("theme.zig").default_theme; var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null); defer rendered.deinit(alloc); try std.testing.expectEqual(@as(u16, 150), rendered.width); try std.testing.expectEqual(@as(u16, 80), rendered.height); switch (rendered.surface) { .image_surface_rgb => {}, else => try std.testing.expect(false), } } test "renderToSurface fills background with theme bg" { const alloc = std.testing.allocator; const bands = [_]projections.YearPercentiles{ .{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 }, .{ .year = 1, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 }, }; var th = @import("theme.zig").default_theme; th.bg = .{ 0xab, 0xcd, 0xef }; var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null); defer rendered.deinit(alloc); const buf = switch (rendered.surface) { .image_surface_rgb => |s| s.buf, else => unreachable, }; try std.testing.expectEqual(@as(u8, 0xab), buf[0].r); try std.testing.expectEqual(@as(u8, 0xcd), buf[0].g); try std.testing.expectEqual(@as(u8, 0xef), buf[0].b); } test "renderToSurface is deterministic across calls with same input" { const alloc = std.testing.allocator; const bands = [_]projections.YearPercentiles{ .{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 }, .{ .year = 5, .p10 = 90, .p25 = 110, .p50 = 130, .p75 = 160, .p90 = 200 }, .{ .year = 10, .p10 = 80, .p25 = 120, .p50 = 160, .p75 = 220, .p90 = 300 }, }; const th = @import("theme.zig").default_theme; var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null); defer a.deinit(alloc); var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null); defer b.deinit(alloc); const buf_a = switch (a.surface) { .image_surface_rgb => |s| s.buf, else => unreachable, }; const buf_b = switch (b.surface) { .image_surface_rgb => |s| s.buf, else => unreachable, }; try std.testing.expectEqual(buf_a.len, buf_b.len); var i: usize = 0; while (i < buf_a.len) : (i += 50) { try std.testing.expectEqual(buf_a[i].r, buf_b[i].r); try std.testing.expectEqual(buf_a[i].g, buf_b[i].g); try std.testing.expectEqual(buf_a[i].b, buf_b[i].b); } } test "RenderedProjection.extractRgb produces 3 bytes per pixel" { const alloc = std.testing.allocator; const bands = [_]projections.YearPercentiles{ .{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }, .{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 }, }; const th = @import("theme.zig").default_theme; var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null); defer rendered.deinit(alloc); const raw = try rendered.extractRgb(alloc); defer alloc.free(raw); const buf = switch (rendered.surface) { .image_surface_rgb => |s| s.buf, else => unreachable, }; try std.testing.expectEqual(buf.len * 3, raw.len); try std.testing.expectEqual(buf[0].r, raw[0]); try std.testing.expectEqual(buf[0].g, raw[1]); try std.testing.expectEqual(buf[0].b, raw[2]); } test "renderToSurface clamps value_min to zero when bands include negatives" { const alloc = std.testing.allocator; // p10 dipping below zero with no padding clamping would make // the chart's negative region uselessly large. const bands = [_]projections.YearPercentiles{ .{ .year = 0, .p10 = -100, .p25 = -50, .p50 = 0, .p75 = 50, .p90 = 100 }, .{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 }, }; const th = @import("theme.zig").default_theme; var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null); defer rendered.deinit(alloc); // After 5% padding and the `if (value_min < 0) value_min = 0` // clamp inside renderToSurface, the recorded min should be 0 // even though the raw band min is negative. try std.testing.expectEqual(@as(f64, 0), rendered.value_min); try std.testing.expect(rendered.value_max > 0); }