provide chart for the projections --vs command
This commit is contained in:
parent
a70e61d873
commit
f107002b93
4 changed files with 520 additions and 15 deletions
|
|
@ -31,6 +31,7 @@ const line_chart = @import("charts/line_chart.zig");
|
|||
const projections = @import("analytics/projections.zig");
|
||||
const forecast = @import("analytics/forecast_evaluation.zig");
|
||||
const forecast_chart = @import("charts/forecast_chart.zig");
|
||||
const compare_chart = @import("charts/compare_chart.zig");
|
||||
const theme = @import("tui/theme.zig");
|
||||
|
||||
/// Default PNG export resolution. Matches `charts/chart.zig`'s
|
||||
|
|
@ -189,6 +190,34 @@ pub fn exportBacktestChart(
|
|||
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
||||
}
|
||||
|
||||
/// Export the `--vs` projection comparison overlay - two percentile-
|
||||
/// band envelopes ("then" and "now") on one chart. Wraps
|
||||
/// `compare_chart.renderToSurface` + `writeToPNGFile`.
|
||||
pub fn exportCompareChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
then_bands: []const projections.YearPercentiles,
|
||||
now_bands: []const projections.YearPercentiles,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
var rendered = compare_chart.renderToSurface(
|
||||
io,
|
||||
alloc,
|
||||
then_bands,
|
||||
now_bands,
|
||||
default_width,
|
||||
default_height,
|
||||
theme.default_theme,
|
||||
true,
|
||||
) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
else => return err,
|
||||
};
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
test "exportSymbolChart writes a non-empty PNG file" {
|
||||
|
|
@ -497,3 +526,59 @@ test "exportBacktestChart returns InsufficientData with a single anchor" {
|
|||
exportBacktestChart(io, alloc, &anchors, path),
|
||||
);
|
||||
}
|
||||
|
||||
test "exportCompareChart writes a non-empty PNG file" {
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
var then_bands: [11]projections.YearPercentiles = undefined;
|
||||
var now_bands: [11]projections.YearPercentiles = undefined;
|
||||
for (0..11) |i| {
|
||||
const t: f64 = 1_000_000.0 * (1.0 + 0.05 * @as(f64, @floatFromInt(i)));
|
||||
const n: f64 = 1_200_000.0 * (1.0 + 0.06 * @as(f64, @floatFromInt(i)));
|
||||
then_bands[i] = .{ .year = @intCast(i), .p10 = t * 0.6, .p25 = t * 0.8, .p50 = t, .p75 = t * 1.2, .p90 = t * 1.5 };
|
||||
now_bands[i] = .{ .year = @intCast(i), .p10 = n * 0.6, .p25 = n * 0.8, .p50 = n, .p75 = n * 1.2, .p90 = n * 1.5 };
|
||||
}
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_compare.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportCompareChart(io, alloc, &then_bands, &now_bands, path);
|
||||
|
||||
var file = try tmp.dir.openFile(io, "test_export_compare.png", .{});
|
||||
defer file.close(io);
|
||||
const size = (try file.stat(io)).size;
|
||||
try std.testing.expect(size > 1024);
|
||||
|
||||
var magic: [8]u8 = undefined;
|
||||
var reader = file.reader(io, &.{});
|
||||
_ = try reader.interface.readSliceShort(&magic);
|
||||
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
||||
}
|
||||
|
||||
test "exportCompareChart returns InsufficientData with a single-year side" {
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
const then_bands = [_]projections.YearPercentiles{.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }};
|
||||
var now_bands: [3]projections.YearPercentiles = undefined;
|
||||
for (0..3) |i| now_bands[i] = .{ .year = @intCast(i), .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 };
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_compare_insufficient.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try std.testing.expectError(
|
||||
error.InsufficientData,
|
||||
exportCompareChart(io, alloc, &then_bands, &now_bands, path),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
328
src/charts/compare_chart.zig
Normal file
328
src/charts/compare_chart.zig
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
//! 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),
|
||||
);
|
||||
}
|
||||
|
|
@ -10,9 +10,11 @@
|
|||
//! Solid 1-bit pixels avoid it entirely.
|
||||
//! 2. It keeps a ~hundreds-of-KB TTF (and its license) out of the repo.
|
||||
//!
|
||||
//! The glyph set is intentionally minimal - just what axis labels need:
|
||||
//! digits, `$`, `.`, `,`, `-`, and the `T`/`B`/`M` magnitude suffixes
|
||||
//! emitted by `format.fmtLargeNum`, plus space. Unknown chars render blank.
|
||||
//! The glyph set is intentionally minimal - just what axis labels and
|
||||
//! chart legends need: digits, `$`, `.`, `,`, `-`, the `T`/`B`/`M`
|
||||
//! magnitude suffixes emitted by `format.fmtLargeNum`, and the
|
||||
//! lowercase letters `t`/`h`/`e`/`n`/`o`/`w` for the comparison
|
||||
//! chart's "then"/"now" legend. Space and unknown chars render blank.
|
||||
//!
|
||||
//! Coordinates are in surface pixels; `scale` multiplies the 5x7 cell
|
||||
//! (so `scale = 3` renders 15x21 glyphs). Drawing is clipped to the
|
||||
|
|
@ -59,6 +61,16 @@ const glyph_T: Glyph = .{ 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 };
|
|||
const glyph_B: Glyph = .{ 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E };
|
||||
const glyph_M: Glyph = .{ 0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11 };
|
||||
|
||||
// Lowercase letters - just what the comparison chart's "then"/"now"
|
||||
// legend needs (t, h, e, n, o, w). 5x7, low 5 bits per row. Named
|
||||
// `lc_*` to avoid colliding with the `glyph_w`/`glyph_h` cell dims.
|
||||
const lc_t: Glyph = .{ 0x08, 0x08, 0x1C, 0x08, 0x08, 0x08, 0x0C };
|
||||
const lc_h: Glyph = .{ 0x10, 0x10, 0x10, 0x1E, 0x12, 0x12, 0x12 };
|
||||
const lc_e: Glyph = .{ 0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E };
|
||||
const lc_n: Glyph = .{ 0x00, 0x00, 0x1E, 0x12, 0x12, 0x12, 0x12 };
|
||||
const lc_o: Glyph = .{ 0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E };
|
||||
const lc_w: Glyph = .{ 0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A };
|
||||
|
||||
/// Look up the bitmap for a character. Unknown characters (including
|
||||
/// space) render blank.
|
||||
fn glyphFor(ch: u8) Glyph {
|
||||
|
|
@ -71,6 +83,12 @@ fn glyphFor(ch: u8) Glyph {
|
|||
'T' => glyph_T,
|
||||
'B' => glyph_B,
|
||||
'M' => glyph_M,
|
||||
't' => lc_t,
|
||||
'h' => lc_h,
|
||||
'e' => lc_e,
|
||||
'n' => lc_n,
|
||||
'o' => lc_o,
|
||||
'w' => lc_w,
|
||||
else => blank,
|
||||
};
|
||||
}
|
||||
|
|
@ -209,3 +227,13 @@ test "drawText renders the comma glyph (so thousands separators show)" {
|
|||
// The comma bitmap (rows 0x06,0x06,0x08) has 5 set pixels.
|
||||
try testing.expectEqual(@as(usize, 5), draw.countColor(&sfc, white));
|
||||
}
|
||||
|
||||
test "drawText renders the lowercase legend letters (then/now)" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 16);
|
||||
defer sfc.deinit(alloc);
|
||||
const white = [3]u8{ 0xFF, 0xFF, 0xFF };
|
||||
// "thenow" exercises all six lowercase glyphs; none may be blank.
|
||||
drawText(&sfc, 1, 1, 1, white, "thenow");
|
||||
try testing.expect(draw.countColor(&sfc, white) > 30);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const chart_export = @import("../chart_export.zig");
|
|||
const projection_chart = @import("../charts/projection_chart.zig");
|
||||
const projections = @import("../analytics/projections.zig");
|
||||
const forecast_chart = @import("../charts/forecast_chart.zig");
|
||||
const compare_chart = @import("../charts/compare_chart.zig");
|
||||
const braille = @import("../charts/braille.zig");
|
||||
const term_graphics = @import("../term_graphics.zig");
|
||||
const term_query = @import("../term_query.zig");
|
||||
|
|
@ -68,6 +69,9 @@ pub const CompareArgs = struct {
|
|||
/// "Now" side. Null = today (live); non-null = the `--as-of`
|
||||
/// date the user paired with `--vs`.
|
||||
as_of: ?Date = null,
|
||||
/// When set, render the side-by-side comparison overlay as a PNG
|
||||
/// to this path and exit. No text output.
|
||||
export_chart: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub const meta: framework.Meta = .{
|
||||
|
|
@ -110,10 +114,10 @@ pub const meta: framework.Meta = .{
|
|||
\\ CPI-adjusted dollars.
|
||||
\\ --export-chart <PATH> Render the current mode's chart as a
|
||||
\\ PNG to PATH (1920x1080) and exit.
|
||||
\\ Works in the default bands mode (with
|
||||
\\ the overlay if --overlay-actuals is
|
||||
\\ set), --convergence, and
|
||||
\\ --return-backtest. Not valid with --vs.
|
||||
\\ Works in all modes: the default bands
|
||||
\\ view (with the overlay if
|
||||
\\ --overlay-actuals is set), --convergence,
|
||||
\\ --return-backtest, and --vs.
|
||||
\\
|
||||
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
|
||||
\\
|
||||
|
|
@ -216,14 +220,6 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
cli.stderrPrint(io, "Error: --overlay-actuals requires --as-of.\n");
|
||||
return error.MutuallyExclusive;
|
||||
}
|
||||
// --vs is text-only (a then/now delta table) with no chart to
|
||||
// export. The bands, convergence, and return-backtest modes all
|
||||
// support --export-chart.
|
||||
if (export_chart != null and vs_date != null) {
|
||||
cli.stderrPrint(io, "Error: --export-chart is not supported with --vs (it has no chart).\n");
|
||||
return error.MutuallyExclusive;
|
||||
}
|
||||
|
||||
if (convergence) return ParsedArgs{ .convergence = .{ .export_chart = export_chart } };
|
||||
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode, .export_chart = export_chart } };
|
||||
if (vs_date) |d| {
|
||||
|
|
@ -231,6 +227,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
.events_enabled = events_enabled,
|
||||
.vs_date = d,
|
||||
.as_of = as_of,
|
||||
.export_chart = export_chart,
|
||||
} };
|
||||
}
|
||||
return ParsedArgs{ .bands = .{
|
||||
|
|
@ -294,6 +291,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
.today = today,
|
||||
.live = if (live) |*l| l else null,
|
||||
},
|
||||
kitty_caps,
|
||||
args.export_chart,
|
||||
);
|
||||
},
|
||||
.bands => |args| {
|
||||
|
|
@ -1062,6 +1061,15 @@ fn extractKeyMetrics(ctx: view.ProjectionContext) KeyMetrics {
|
|||
};
|
||||
}
|
||||
|
||||
/// The longest-horizon percentile band envelope for a context, or
|
||||
/// null when no horizon/bands are available. Used to retain the
|
||||
/// `--vs` comparison overlay's two envelopes.
|
||||
fn longestBands(ctx: view.ProjectionContext) ?[]const projections.YearPercentiles {
|
||||
const horizons = ctx.config.getHorizons();
|
||||
if (horizons.len == 0) return null;
|
||||
return ctx.data.bands[horizons.len - 1];
|
||||
}
|
||||
|
||||
/// Build a `ProjectionContext` for the `--vs` / `compare --projections`
|
||||
/// "then" or snapshot "now" side at `requested_date`.
|
||||
///
|
||||
|
|
@ -1121,6 +1129,8 @@ pub fn runCompare(
|
|||
ctx: *framework.RunCtx,
|
||||
file_path: []const u8,
|
||||
opts: KeyComparisonOptions,
|
||||
kitty_caps: ?term_query.Caps,
|
||||
export_chart: ?[]const u8,
|
||||
) !void {
|
||||
const io = ctx.io;
|
||||
const allocator = ctx.allocator;
|
||||
|
|
@ -1138,6 +1148,26 @@ pub fn runCompare(
|
|||
};
|
||||
defer result.cleanup();
|
||||
|
||||
// --export-chart: render the comparison overlay to a PNG and exit
|
||||
// before any text output.
|
||||
if (export_chart) |export_path| {
|
||||
if (result.then_bands == null or result.now_bands == null) {
|
||||
cli.stderrPrint(io, "Error: projection bands unavailable for one side; cannot export comparison chart.\n");
|
||||
return;
|
||||
}
|
||||
chart_export.exportCompareChart(io, allocator, result.then_bands.?, result.now_bands.?, export_path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
cli.stderrPrint(io, "Error: not enough projection data to render a comparison chart.\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
cli.stderrPrint(io, "Error: failed to write PNG.\n");
|
||||
return err;
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
try out.print("\n", .{});
|
||||
var then_buf: [10]u8 = undefined;
|
||||
var now_buf: [10]u8 = undefined;
|
||||
|
|
@ -1178,6 +1208,21 @@ pub fn runCompare(
|
|||
}
|
||||
try out.print("\n", .{});
|
||||
|
||||
// Inline comparison overlay above the table when supported. No
|
||||
// braille fallback - non-kitty terminals get the table only.
|
||||
if (kitty_caps) |kc| {
|
||||
if (result.then_bands != null and result.now_bands != null) {
|
||||
const d = projectionChartDims(kc);
|
||||
if (compare_chart.renderCompareChart(io, va, result.then_bands.?, result.now_bands.?, d.width, d.height, theme.default_theme)) |cres| {
|
||||
try term_graphics.placeInline(out, va, cres.rgb_data, d.width, d.height, d.cols, d.rows);
|
||||
try out.print("\n", .{});
|
||||
} else |err| switch (err) {
|
||||
error.InsufficientData => {},
|
||||
else => return err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try renderKeyComparisonRows(out, color, result.then, result.now, result.events_enabled);
|
||||
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, "\nFor the full benchmark + SWR tables run `zfin projections --as-of {s}` and `zfin projections{s}`.\n", .{
|
||||
|
|
@ -1372,6 +1417,11 @@ fn renderForecastLines(
|
|||
pub const KeyComparisonResult = struct {
|
||||
then: KeyMetrics,
|
||||
now: KeyMetrics,
|
||||
/// Longest-horizon percentile bands for each side, retained for
|
||||
/// the `--vs` comparison overlay chart. Arena-lived (the caller's
|
||||
/// `va`); null when a side produced no bands. Both aligned at year 0.
|
||||
then_bands: ?[]const projections.YearPercentiles = null,
|
||||
now_bands: ?[]const projections.YearPercentiles = null,
|
||||
/// Resolution of the "then" snapshot. Always present.
|
||||
resolution: AsOfResolution,
|
||||
/// Resolution of the "now" snapshot. Null when now is live.
|
||||
|
|
@ -1507,6 +1557,8 @@ pub fn computeKeyComparison(
|
|||
return .{
|
||||
.then = extractKeyMetrics(then_ctx),
|
||||
.now = extractKeyMetrics(now_ctx),
|
||||
.then_bands = longestBands(then_ctx),
|
||||
.now_bands = longestBands(now_ctx),
|
||||
.resolution = then_resolution,
|
||||
.now_resolution = now_resolution,
|
||||
.events_enabled = opts.events_enabled,
|
||||
|
|
@ -1540,6 +1592,8 @@ pub fn computeKeyComparison(
|
|||
return .{
|
||||
.then = extractKeyMetrics(then_ctx),
|
||||
.now = extractKeyMetrics(now_ctx),
|
||||
.then_bands = longestBands(then_ctx),
|
||||
.now_bands = longestBands(now_ctx),
|
||||
.resolution = then_resolution,
|
||||
.now_resolution = null,
|
||||
.events_enabled = opts.events_enabled,
|
||||
|
|
@ -1954,6 +2008,16 @@ test "projectionChartDims: standard column width, sane pixel/row footprint" {
|
|||
try testing.expect(d.width > 0 and d.height > 0);
|
||||
}
|
||||
|
||||
test "parseArgs: --vs with --export-chart carries into compare" {
|
||||
const today = Date.fromYmd(2026, 5, 9);
|
||||
const args = [_][]const u8{ "--vs", "2024-01-01", "--export-chart", "out.png" };
|
||||
const parsed = try parseArgsForTest(today, &args);
|
||||
switch (parsed) {
|
||||
.compare => |c| try testing.expect(c.export_chart != null),
|
||||
else => try testing.expect(false),
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot_model = @import("../models/snapshot.zig");
|
||||
const snapshot = @import("snapshot.zig");
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue