zfin/src/charts/compare_chart.zig

328 lines
13 KiB
Zig

//! Projection comparison chart: overlays two percentile-band
//! envelopes - a "then" projection (computed as of a past snapshot)
//! and a "now" projection - so the viewer can see how the forecast
//! envelope shifted between the two dates.
//!
//! Both projections are aligned at year 0 (each one's own start), so
//! the x-axis is "years from the projection's start" and the overlay
//! answers "how did my projected envelope move between then and now?".
//!
//! Each side draws a light p10-p90 fill plus a solid median line in
//! its own hue (then = `theme.info` / cyan, now = `theme.accent` /
//! purple), keyed by a small top-left "then"/"now" color legend.
//!
//! Visual layers (bottom to top):
//! - Background
//! - Horizontal grid lines
//! - "then" envelope fill, then "now" envelope fill
//! - "then" median, then "now" median (both on top of both fills)
//! - Zero line (if visible)
//! - Panel border
//! - then/now color legend (top-left)
//! - Axis labels (export only)
const std = @import("std");
const z2d = @import("z2d");
const theme = @import("../tui/theme.zig");
const projections = @import("../analytics/projections.zig");
const draw = @import("draw.zig");
const axis = @import("axis.zig");
const text = @import("text.zig");
const Surface = z2d.Surface;
const Context = z2d.Context;
const margin_left: f64 = 4;
const margin_right: f64 = 4;
const margin_top: f64 = 4;
const margin_bottom: f64 = 4;
/// Comparison chart render result (RGB for kitty graphics).
pub const CompareChartResult = struct {
rgb_data: []const u8,
width: u16,
height: u16,
value_min: f64,
value_max: f64,
};
/// Owned by the caller - call `result.deinit(alloc)` when done. The
/// surface seam shared between RGB extraction (kitty) and PNG export.
/// Mirrors `projection_chart.RenderedProjection`.
pub const RenderedCompare = struct {
surface: Surface,
width: u16,
height: u16,
value_min: f64,
value_max: f64,
pub fn deinit(self: *RenderedCompare, alloc: std.mem.Allocator) void {
self.surface.deinit(alloc);
self.* = undefined;
}
pub fn extractRgb(self: *const RenderedCompare, alloc: std.mem.Allocator) ![]u8 {
return draw.extractRgb(alloc, &self.surface);
}
};
/// Render the "then" vs "now" comparison overlay into a `Surface`.
/// Both band slices are aligned at year 0; the x-axis spans the
/// longer of the two horizons. With `axis_labels`, reserves margins
/// and stamps y-axis dollar ticks + x-axis year endpoints (export
/// path); the kitty wrapper passes `false`.
pub fn renderToSurface(
io: std.Io,
alloc: std.mem.Allocator,
then_bands: []const projections.YearPercentiles,
now_bands: []const projections.YearPercentiles,
width_px: u32,
height_px: u32,
th: theme.Theme,
axis_labels: bool,
) !RenderedCompare {
if (then_bands.len < 2 or now_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);
try draw.fillBackground(&ctx, fwidth, fheight, bg);
// Chart area. With axis labels, reserve a right margin for the
// y-axis dollar ticks and a bottom margin for the year endpoints.
const label_scale: i32 = axis.labelScale(h);
const label_char_h: f64 = axis.charHeight(label_scale);
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
const chart_left = m_left;
const chart_right = fwidth - m_right;
const chart_w = chart_right - chart_left;
const chart_top = m_top;
const chart_bottom = fheight - m_bottom;
// Value range across BOTH envelopes (p10 floor, p90 ceiling).
var value_min: f64 = then_bands[0].p10;
var value_max: f64 = then_bands[0].p90;
for (then_bands) |bp| {
if (bp.p10 < value_min) value_min = bp.p10;
if (bp.p90 > value_max) value_max = bp.p90;
}
for (now_bands) |bp| {
if (bp.p10 < value_min) value_min = bp.p10;
if (bp.p90 > value_max) value_max = bp.p90;
}
const pad = (value_max - value_min) * 0.05;
value_min -= pad;
value_max += pad;
if (value_min < 0) value_min = 0;
// X step: align both at year 0; the longer horizon spans the full
// width. `bands[i].year == i`, so index doubles as the year offset.
const n = @max(then_bands.len, now_bands.len);
const x_step = chart_w / @as(f64, @floatFromInt(n - 1));
// Grid lines.
try draw.drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, draw.blendColor(th.text_muted, 40, bg));
// Envelopes: draw BOTH light fills first, then BOTH medians on
// top. Rendering uses the `.src` operator (replace, not blend), so
// drawing a fill after a median would occlude that median - hence
// the two-pass order. "now" fill goes on top of "then" fill.
try drawEnvelopeFill(&ctx, then_bands, chart_left, x_step, value_min, value_max, chart_top, chart_bottom, th.info, bg);
try drawEnvelopeFill(&ctx, now_bands, chart_left, x_step, value_min, value_max, chart_top, chart_bottom, th.accent, bg);
try drawEnvelopeMedian(&ctx, then_bands, chart_left, x_step, value_min, value_max, chart_top, chart_bottom, th.info);
try drawEnvelopeMedian(&ctx, now_bands, chart_left, x_step, value_min, value_max, chart_top, chart_bottom, th.accent);
// Zero line (if visible).
if (value_min <= 0 and value_max > 0) {
const zero_y = draw.mapY(0, value_min, value_max, chart_top, chart_bottom);
try draw.drawHLine(&ctx, chart_left, chart_right, zero_y, draw.blendColor(th.negative, 120, bg), 1.0);
}
// Panel border.
try draw.drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, draw.blendColor(th.border, 80, bg), 1.0);
// Color legend (top-left): keys the two envelope hues. Two
// unlabeled colored medians would otherwise be ambiguous.
{
const lgh: i32 = @intFromFloat(axis.charHeight(label_scale));
const lx: i32 = @as(i32, @intFromFloat(chart_left)) + 4 * label_scale;
const ly: i32 = @as(i32, @intFromFloat(chart_top)) + 2 * label_scale;
text.drawText(&sfc, lx, ly, label_scale, th.info, "then");
text.drawText(&sfc, lx, ly + lgh + 2 * label_scale, label_scale, th.accent, "now");
}
// Axis labels (export only): y dollar ticks + x year endpoints.
if (axis_labels) {
axis.drawYTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, value_min, value_max, 5, .dollars);
var fbuf: [8]u8 = undefined;
var lbuf: [8]u8 = undefined;
const first_s = std.fmt.bufPrint(&fbuf, "{d}", .{0}) catch "0";
const last_s = std.fmt.bufPrint(&lbuf, "{d}", .{n - 1}) catch "";
const yr_y = chart_bottom + axis.labelGap(label_scale);
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, yr_y, first_s, last_s);
}
return .{
.surface = sfc,
.width = @intCast(width_px),
.height = @intCast(height_px),
.value_min = value_min,
.value_max = value_max,
};
}
/// Thin RGB wrapper over `renderToSurface` for the inline kitty path:
/// renders without axis labels, extracts RGB, frees the surface.
pub fn renderCompareChart(
io: std.Io,
alloc: std.mem.Allocator,
then_bands: []const projections.YearPercentiles,
now_bands: []const projections.YearPercentiles,
width_px: u32,
height_px: u32,
th: theme.Theme,
) !CompareChartResult {
var rendered = try renderToSurface(io, alloc, then_bands, now_bands, width_px, height_px, th, false);
defer rendered.deinit(alloc);
return .{
.rgb_data = try rendered.extractRgb(alloc),
.width = rendered.width,
.height = rendered.height,
.value_min = rendered.value_min,
.value_max = rendered.value_max,
};
}
/// Draw one envelope's light p10-p90 fill in `hue`. Indices map to x
/// via `chart_left + i * x_step`.
fn drawEnvelopeFill(
ctx: *Context,
bands: []const projections.YearPercentiles,
chart_left: f64,
x_step: f64,
value_min: f64,
value_max: f64,
chart_top: f64,
chart_bottom: f64,
hue: [3]u8,
bg: [3]u8,
) !void {
ctx.setSourceToPixel(draw.blendColor(hue, 22, bg));
ctx.resetPath();
for (bands, 0..) |bp, i| {
const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
const y = draw.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);
}
var j: usize = bands.len;
while (j > 0) {
j -= 1;
const x = chart_left + @as(f64, @floatFromInt(j)) * x_step;
const y = draw.mapY(bands[j].p10, value_min, value_max, chart_top, chart_bottom);
try ctx.lineTo(x, y);
}
try ctx.closePath();
try ctx.fill();
}
/// Draw one envelope's solid p50 median line in `hue`.
fn drawEnvelopeMedian(
ctx: *Context,
bands: []const projections.YearPercentiles,
chart_left: f64,
x_step: f64,
value_min: f64,
value_max: f64,
chart_top: f64,
chart_bottom: f64,
hue: [3]u8,
) !void {
ctx.setSourceToPixel(draw.opaqueColor(hue));
ctx.setLineWidth(2.0);
ctx.resetPath();
for (bands, 0..) |bp, i| {
const x = chart_left + @as(f64, @floatFromInt(i)) * x_step;
const y = draw.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();
}
// ── Tests ─────────────────────────────────────────────────────────────
fn syntheticBands(buf: []projections.YearPercentiles, base: f64, growth: f64) []projections.YearPercentiles {
for (buf, 0..) |*b, i| {
const v = base * (1.0 + growth * @as(f64, @floatFromInt(i)));
b.* = .{
.year = @intCast(i),
.p10 = v * 0.6,
.p25 = v * 0.8,
.p50 = v,
.p75 = v * 1.2,
.p90 = v * 1.5,
};
}
return buf;
}
test "renderCompareChart produces valid RGB output for two envelopes" {
const alloc = std.testing.allocator;
var then_buf: [11]projections.YearPercentiles = undefined;
var now_buf: [11]projections.YearPercentiles = undefined;
const then_bands = syntheticBands(&then_buf, 1_000_000, 0.05);
const now_bands = syntheticBands(&now_buf, 1_200_000, 0.06);
const th = theme.default_theme;
const result = try renderCompareChart(std.testing.io, alloc, then_bands, now_bands, 240, 120, th);
defer alloc.free(result.rgb_data);
try std.testing.expectEqual(@as(u16, 240), result.width);
try std.testing.expectEqual(@as(u16, 120), result.height);
try std.testing.expectEqual(@as(usize, 240 * 120 * 3), result.rgb_data.len);
try std.testing.expect(result.value_max > result.value_min);
}
test "renderToSurface draws both envelope hues" {
const alloc = std.testing.allocator;
var then_buf: [11]projections.YearPercentiles = undefined;
var now_buf: [11]projections.YearPercentiles = undefined;
const then_bands = syntheticBands(&then_buf, 1_000_000, 0.03);
const now_bands = syntheticBands(&now_buf, 1_500_000, 0.07);
const th = theme.default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, then_bands, now_bands, 200, 100, th, false);
defer rendered.deinit(alloc);
// Both median hues should have painted opaque pixels.
try std.testing.expect(draw.countColor(&rendered.surface, th.info) > 0);
try std.testing.expect(draw.countColor(&rendered.surface, th.accent) > 0);
}
test "renderToSurface insufficient data on a single-year band" {
const alloc = std.testing.allocator;
var then_buf: [1]projections.YearPercentiles = undefined;
var now_buf: [11]projections.YearPercentiles = undefined;
const then_bands = syntheticBands(&then_buf, 1_000_000, 0.05);
const now_bands = syntheticBands(&now_buf, 1_000_000, 0.05);
const th = theme.default_theme;
try std.testing.expectError(
error.InsufficientData,
renderToSurface(std.testing.io, alloc, then_bands, now_bands, 200, 100, th, false),
);
}