328 lines
13 KiB
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),
|
|
);
|
|
}
|