630 lines
24 KiB
Zig
630 lines
24 KiB
Zig
//! 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);
|
|
}
|