//! Forecast-evaluation chart renderer using z2d. //! //! Sibling to `projection_chart.zig` for plain line-shaped charts //! (no percentile bands). Used by the projections tab's //! convergence and return-back-test sub-views. //! //! Two render entry points: //! - `renderConvergenceChart`: single-series line of //! years-until-retirement vs. observation date, with a dashed //! `slope=-1` reference line for "perfect convergence" and //! small markers on `reached` rows. //! - `renderBacktestChart`: multi-series line chart showing //! `expected_return` (primary, solid) alongside realized 1y/3y/5y //! forward CAGR (faint, line styles vary by horizon). Y=0 //! reference line for sanity. //! //! Both produce raw RGB pixel data for Kitty graphics protocol //! transmission, mirroring `projection_chart.zig`'s output shape. //! //! The two functions share substantial scaffolding (margins, //! axes, grid lines, value-range expansion). Helpers are //! file-private; `renderProjectionChart`'s helpers are re-derived //! locally to avoid leaking implementation details across the //! module boundary. Sibling rather than shared because the chart //! shapes are different enough that a shared core would be //! awkwardly parameterized. const std = @import("std"); const z2d = @import("z2d"); const theme = @import("theme.zig"); const forecast = @import("../analytics/forecast_evaluation.zig"); const Date = @import("../Date.zig"); const Surface = z2d.Surface; const Context = z2d.Context; const Pixel = z2d.Pixel; const margin_left: f64 = 4; const margin_right: f64 = 4; const margin_top: f64 = 4; const margin_bottom: f64 = 4; pub const ChartResult = struct { rgb_data: []const u8, width: u16, height: u16, /// Y-range used; renderers may want this for label rendering. value_min: f64, value_max: f64, }; // ── View 1: Convergence chart ──────────────────────────────── /// Render the convergence chart. X-axis spans /// `points[0].observation_date` to /// `points[points.len-1].observation_date`. Y-axis is /// years-until-retirement (Encoding B per the spec). /// /// Visual layers (bottom to top): /// - Background /// - Horizontal grid lines (at y values 0, 5, 10, ...) /// - Dashed `slope=-1` reference line: at the leftmost x it /// starts at `points[0].years_until_retirement` and decreases /// by 1 year per year of x progression. This is "what the /// line would look like if the model converged perfectly." /// - Solid line through the convergence points /// - Distinct markers on `reached` rows (small filled dots, /// theme accent color) pub fn renderConvergenceChart( io: std.Io, alloc: std.mem.Allocator, points: []const forecast.ConvergencePoint, width_px: u32, height_px: u32, th: theme.Theme, ) !ChartResult { if (points.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(); 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; // X-range: observation_date span const x0_days: f64 = @floatFromInt(points[0].observation_date.days); const x1_days: f64 = @floatFromInt(points[points.len - 1].observation_date.days); const x_span: f64 = if (x1_days > x0_days) x1_days - x0_days else 1.0; // Y-range: years_until_retirement, padded const y_min: f64 = 0; var y_max: f64 = 0; for (points) |p| { if (p.years_until_retirement > y_max) y_max = p.years_until_retirement; } // The reference line ends at `points[0].years_until_retirement - // (x1 - x0) / 365.25`, which can be negative. Clamp the y-range // floor at 0 - negative years-until-retirement isn't a // meaningful display value. if (y_max < 1) y_max = 1; // ensure at least a 1-year scale const y_pad = y_max * 0.1; y_max += y_pad; // 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); // Reference line: slope = -1 year/year, starting at the leftmost // anchor's years_until_retirement value. If a point converges // perfectly it'd lie on this reference. { const ref_start_y = points[0].years_until_retirement; const x_years_span = x_span / 365.25; const ref_end_y = ref_start_y - x_years_span; const ref_color = blendColor(th.text_muted, 100, bg); ctx.setSourceToPixel(ref_color); ctx.setLineWidth(1.0); // Dashed: emit segment-pairs. const dash_len: f64 = 6.0; const gap_len: f64 = 4.0; var dx: f64 = 0; const total_pixels = chart_w; while (dx < total_pixels) { const dx_end = @min(dx + dash_len, total_pixels); const f0 = dx / total_pixels; const f1 = dx_end / total_pixels; const y0 = mapY(ref_start_y + (ref_end_y - ref_start_y) * f0, y_min, y_max, chart_top, chart_bottom); const y1 = mapY(ref_start_y + (ref_end_y - ref_start_y) * f1, y_min, y_max, chart_top, chart_bottom); ctx.resetPath(); try ctx.moveTo(chart_left + dx, y0); try ctx.lineTo(chart_left + dx_end, y1); try ctx.stroke(); dx = dx_end + gap_len; } ctx.setLineWidth(2.0); } // Main series: solid line through all points, theme accent. { ctx.setSourceToPixel(opaqueColor(th.accent)); ctx.setLineWidth(2.0); ctx.resetPath(); for (points, 0..) |p, i| { const dx_days: f64 = @floatFromInt(p.observation_date.days); const x_frac = (dx_days - x0_days) / x_span; const x = chart_left + x_frac * chart_w; const y = mapY(p.years_until_retirement, y_min, y_max, chart_top, chart_bottom); if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); } try ctx.stroke(); } // Reached markers (small filled dots). { ctx.setSourceToPixel(opaqueColor(th.positive)); const dot_radius: f64 = 2.5; for (points) |p| { if (!p.reached) continue; const dx_days: f64 = @floatFromInt(p.observation_date.days); const x_frac = (dx_days - x0_days) / x_span; const x = chart_left + x_frac * chart_w; const y = mapY(p.years_until_retirement, y_min, y_max, chart_top, chart_bottom); try fillCircle(&ctx, x, y, dot_radius); } } // Border try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0); return .{ .rgb_data = try extractRgb(alloc, &sfc), .width = @intCast(width_px), .height = @intCast(height_px), .value_min = y_min, .value_max = y_max, }; } // ── View 2: Return back-test chart ─────────────────────────── /// Pivot of `forecast.BacktestPoint` rows into a single anchor's /// realized-by-horizon view. One per anchor; passed to /// `renderBacktestChart` as the renderer-friendly shape. pub const BacktestAnchor = forecast.BacktestAnchor; /// Render the return back-test chart. X-axis spans the anchor /// dates; y-axis is decimal return rate. Renders four lines with /// distinct hues so the legend is unambiguous; line styles /// (dotted/dashed/solid) reinforce it for color-blind users: /// - `expected` (solid, theme accent - purple) /// - `realized_1y` (dotted, theme info - cyan) /// - `realized_3y` (dashed, theme warning - yellow) /// - `realized_5y` (solid, theme positive - green) /// /// Plus a y=0 reference line. pub fn renderBacktestChart( io: std.Io, alloc: std.mem.Allocator, anchors: []const BacktestAnchor, width_px: u32, height_px: u32, th: theme.Theme, ) !ChartResult { if (anchors.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(); 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; // X-range const x0_days: f64 = @floatFromInt(anchors[0].anchor_date.days); const x1_days: f64 = @floatFromInt(anchors[anchors.len - 1].anchor_date.days); const x_span: f64 = if (x1_days > x0_days) x1_days - x0_days else 1.0; // Y-range across all four series - include realized_* even // when null (skip nulls without contributing). var y_min: f64 = 0; var y_max: f64 = 0; for (anchors) |a| { if (a.expected < y_min) y_min = a.expected; if (a.expected > y_max) y_max = a.expected; if (a.realized_1y) |v| { if (v < y_min) y_min = v; if (v > y_max) y_max = v; } if (a.realized_3y) |v| { if (v < y_min) y_min = v; if (v > y_max) y_max = v; } if (a.realized_5y) |v| { if (v < y_min) y_min = v; if (v > y_max) y_max = v; } } const y_range = y_max - y_min; const y_pad = if (y_range > 0) y_range * 0.10 else 0.05; y_min -= y_pad; y_max += y_pad; if (y_min > 0) y_min = 0; // ensure y=0 is in view for the reference line // Grid lines + y=0 reference (subtle but distinct from the grid). const grid_color = blendColor(th.text_muted, 40, bg); try drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, grid_color); if (y_min < 0 and y_max > 0) { const zero_y = mapY(0, y_min, y_max, chart_top, chart_bottom); try drawHLine(&ctx, chart_left, chart_right, zero_y, blendColor(th.text_muted, 100, bg), 1.0); } // Realized series first (so they're below the expected line in z-order). // Distinct hues per horizon (cyan/yellow/green) so the legend // is unambiguous; line styles (dotted/dashed/solid) reinforce // it for users who are color-blind or running a low-contrast // theme. Keep these aligned with the legend lines emitted by // `drawBacktestWithKitty` in `projections_tab.zig`. try drawSeries(&ctx, anchors, .realized_1y, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.info), 1.5, .dotted); try drawSeries(&ctx, anchors, .realized_3y, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.warning), 1.5, .dashed); try drawSeries(&ctx, anchors, .realized_5y, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.positive), 2.0, .solid); // Expected series last (on top): solid, accent, full opacity, bold width. try drawSeries(&ctx, anchors, .expected, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.accent), 2.0, .solid); // Border try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0); return .{ .rgb_data = try extractRgb(alloc, &sfc), .width = @intCast(width_px), .height = @intCast(height_px), .value_min = y_min, .value_max = y_max, }; } const SeriesKey = enum { expected, realized_1y, realized_3y, realized_5y }; const LineStyle = enum { solid, dashed, dotted }; const DashPattern = struct { on: f64, off: f64 }; fn anchorValue(a: BacktestAnchor, key: SeriesKey) ?f64 { return switch (key) { .expected => a.expected, .realized_1y => a.realized_1y, .realized_3y => a.realized_3y, .realized_5y => a.realized_5y, }; } /// Draw one series across the anchor list, skipping null values. /// Disconnected (null-bridging) segments are emitted as separate /// strokes - the line "lifts" over missing data rather than /// drawing a phantom horizontal segment. fn drawSeries( ctx: *Context, anchors: []const BacktestAnchor, key: SeriesKey, x0_days: f64, x_span: f64, chart_left: f64, chart_w: f64, y_min: f64, y_max: f64, chart_top: f64, chart_bottom: f64, color: Pixel, line_w: f64, style: LineStyle, ) !void { ctx.setSourceToPixel(color); ctx.setLineWidth(line_w); const dash_pattern: ?DashPattern = switch (style) { .solid => null, .dashed => .{ .on = 6.0, .off = 4.0 }, .dotted => .{ .on = 2.0, .off = 3.0 }, }; var have_segment = false; // Emit one stroke per contiguous run of non-null values. // A null value breaks the run. for (anchors, 0..) |a, i| { const v_opt = anchorValue(a, key); if (v_opt) |_| { have_segment = true; // If this is the last anchor, flush the segment. if (i == anchors.len - 1) { try strokeSegment(ctx, anchors, key, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, dash_pattern); have_segment = false; } } else if (have_segment) { // Run broke. Stroke from segment start to last-known endpoint. try strokeSegment(ctx, anchors[0..i], key, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, dash_pattern); have_segment = false; } } ctx.setLineWidth(2.0); } /// Stroke the contiguous non-null segment of `anchors` for `key`. /// For dashed/dotted styles, the segment is rasterized as /// independent dash-length strokes rather than one continuous /// path with z2d's dash array (which we don't use for cross-version /// stability). Solid styles emit one continuous stroke. fn strokeSegment( ctx: *Context, anchors: []const BacktestAnchor, key: SeriesKey, x0_days: f64, x_span: f64, chart_left: f64, chart_w: f64, y_min: f64, y_max: f64, chart_top: f64, chart_bottom: f64, dash: ?DashPattern, ) !void { if (dash) |d| { // Segment-by-segment with manual dashing along each // pixel-length straight line between consecutive points. var prev_x: ?f64 = null; var prev_y: ?f64 = null; for (anchors) |a| { const v_opt = anchorValue(a, key); if (v_opt) |v| { const dx_days: f64 = @floatFromInt(a.anchor_date.days); const x = chart_left + ((dx_days - x0_days) / x_span) * chart_w; const y = mapY(v, y_min, y_max, chart_top, chart_bottom); if (prev_x) |px| { const py = prev_y.?; try drawDashedLine(ctx, px, py, x, y, d.on, d.off); } prev_x = x; prev_y = y; } else { prev_x = null; prev_y = null; } } } else { // Solid: one path, then stroke. var first = true; ctx.resetPath(); for (anchors) |a| { const v_opt = anchorValue(a, key); if (v_opt) |v| { const dx_days: f64 = @floatFromInt(a.anchor_date.days); const x = chart_left + ((dx_days - x0_days) / x_span) * chart_w; const y = mapY(v, y_min, y_max, chart_top, chart_bottom); if (first) { try ctx.moveTo(x, y); first = false; } else { try ctx.lineTo(x, y); } } } if (!first) try ctx.stroke(); } } fn drawDashedLine(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, dash_on: f64, dash_off: f64) !void { const dx = x2 - x1; const dy = y2 - y1; const len = std.math.sqrt(dx * dx + dy * dy); if (len <= 0) return; const ux = dx / len; const uy = dy / len; var t: f64 = 0; while (t < len) { const t_end = @min(t + dash_on, len); const sx = x1 + t * ux; const sy = y1 + t * uy; const ex = x1 + t_end * ux; const ey = y1 + t_end * uy; ctx.resetPath(); try ctx.moveTo(sx, sy); try ctx.lineTo(ex, ey); try ctx.stroke(); t = t_end + dash_off; } } fn fillCircle(ctx: *Context, cx: f64, cy: f64, r: f64) !void { // z2d doesn't expose `arc` here at present; approximate with // an N-sided polygon. 12 sides is plenty for a 2-3 px dot. const n: usize = 12; ctx.resetPath(); var i: usize = 0; while (i < n) : (i += 1) { const ang = @as(f64, @floatFromInt(i)) * 2.0 * std.math.pi / @as(f64, @floatFromInt(n)); const x = cx + r * @cos(ang); const y = cy + r * @sin(ang); if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); } try ctx.closePath(); try ctx.fill(); } // ── Shared helpers (mirrors of projection_chart's privates) ─── 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); } /// Extract raw RGB bytes from an `image_surface_rgb`. Mirrors the /// inline pattern in `projection_chart.zig` so both renderers /// produce the same on-the-wire shape for Kitty graphics /// transmission. Caller owns the returned slice. fn extractRgb(alloc: std.mem.Allocator, sfc: *const Surface) ![]u8 { const rgb_buf = switch (sfc.*) { .image_surface_rgb => |s| s.buf, else => unreachable, }; const out = try alloc.alloc(u8, rgb_buf.len * 3); for (rgb_buf, 0..) |px, i| { out[i * 3 + 0] = px.r; out[i * 3 + 1] = px.g; out[i * 3 + 2] = px.b; } return out; } // ── Tests ───────────────────────────────────────────────────── const testing = std.testing; test "renderConvergenceChart produces RGB output" { const points = [_]forecast.ConvergencePoint{ .{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 10.0, .reached = false }, .{ .observation_date = Date.fromYmd(2022, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 8.0, .reached = false }, .{ .observation_date = Date.fromYmd(2025, 1, 1), .projected_date = Date.fromYmd(2025, 1, 1), .years_until_retirement = 0.0, .reached = true }, }; const th = theme.default_theme; const result = try renderConvergenceChart(testing.io, testing.allocator, &points, 200, 100, th); defer testing.allocator.free(result.rgb_data); try testing.expectEqual(@as(u16, 200), result.width); try testing.expectEqual(@as(u16, 100), result.height); try testing.expectEqual(@as(usize, 200 * 100 * 3), result.rgb_data.len); } test "renderConvergenceChart insufficient data" { const points = [_]forecast.ConvergencePoint{ .{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 10.0, .reached = false }, }; const th = theme.default_theme; const result = renderConvergenceChart(testing.io, testing.allocator, &points, 200, 100, th); try testing.expectError(error.InsufficientData, result); } test "renderBacktestChart produces RGB output with all four series" { const anchors = [_]BacktestAnchor{ .{ .anchor_date = Date.fromYmd(2018, 1, 1), .expected = 0.10, .realized_1y = 0.12, .realized_3y = 0.09, .realized_5y = 0.08 }, .{ .anchor_date = Date.fromYmd(2020, 1, 1), .expected = 0.08, .realized_1y = 0.18, .realized_3y = 0.10, .realized_5y = null }, .{ .anchor_date = Date.fromYmd(2022, 1, 1), .expected = 0.12, .realized_1y = -0.05, .realized_3y = null, .realized_5y = null }, .{ .anchor_date = Date.fromYmd(2024, 1, 1), .expected = 0.07, .realized_1y = null, .realized_3y = null, .realized_5y = null }, }; const th = theme.default_theme; const result = try renderBacktestChart(testing.io, testing.allocator, &anchors, 200, 100, th); defer testing.allocator.free(result.rgb_data); try testing.expectEqual(@as(u16, 200), result.width); try testing.expect(result.value_max > result.value_min); // Y range should include at least y=0 (we force it in) try testing.expect(result.value_min <= 0); } test "renderBacktestChart insufficient data" { const anchors = [_]BacktestAnchor{ .{ .anchor_date = Date.fromYmd(2020, 1, 1), .expected = 0.10, .realized_1y = null, .realized_3y = null, .realized_5y = null }, }; const th = theme.default_theme; const result = renderBacktestChart(testing.io, testing.allocator, &anchors, 200, 100, th); try testing.expectError(error.InsufficientData, result); }