centralize charting/extract shared helpers, prepare for kitty-chart history

This commit is contained in:
Emil Lerch 2026-06-25 14:11:44 -07:00
parent 5a2b29fdd4
commit e6ec5fdac1
Signed by: lobo
GPG key ID: A7B62D657EF764F8
13 changed files with 929 additions and 365 deletions

View file

@ -147,7 +147,7 @@ pub fn rsi(
/// Extract chart-ready close prices from candles into a contiguous f64 slice.
/// Uses `Candle.chartClose()` (split-adjusted when available) so chart
/// renderers don't show false cliffs at split dates. The only callers
/// today are chart code paths in `tui/chart.zig`; if a future caller
/// today are chart code paths in `charts/chart.zig`; if a future caller
/// genuinely needs raw `close`, add a separate `rawClosePrices` helper
/// rather than re-purposing this one.
pub fn closePrices(alloc: std.mem.Allocator, candles: []const Candle) ![]f64 {

View file

@ -299,6 +299,33 @@ pub fn extractMetric(
return out;
}
/// Extract a single metric into `MetricPoint`s for charting, skipping
/// imported-only points for the derived metrics (`.illiquid` /
/// `.net_worth`) that `imported_values.srf` does not carry. Imported
/// rows record only `liquid`; their illiquid/net_worth read as zero, so
/// including them would yank a chart line down to zero across the
/// imported-only range. For `.liquid`, every point is kept.
///
/// Result is caller-owned and preserves the input's ascending-by-date
/// order. This is the single home for the "skip imported-only for
/// derived metrics" rule shared by the CLI braille chart, the CLI
/// `--export-chart` / inline path, and the TUI history chart.
pub fn extractChartSeries(
allocator: std.mem.Allocator,
points: []const TimelinePoint,
metric: Metric,
) ![]MetricPoint {
const skip_imported = (metric == .illiquid) or (metric == .net_worth);
var list: std.ArrayList(MetricPoint) = .empty;
errdefer list.deinit(allocator);
try list.ensureTotalCapacity(allocator, points.len);
for (points) |p| {
if (skip_imported and p.source == .imported) continue;
list.appendAssumeCapacity(.{ .date = p.as_of_date, .value = extractValue(p, metric) });
}
return list.toOwnedSlice(allocator);
}
/// Which collection of per-row named values on `TimelinePoint` to project.
pub const NamedSeriesSource = enum { accounts, tax_types };
@ -1707,6 +1734,47 @@ test "Metric.label: stable strings" {
try testing.expectEqualStrings("Illiquid", Metric.illiquid.label());
}
test "extractChartSeries: liquid keeps imported-only points" {
const points = [_]TimelinePoint{
.{ .as_of_date = Date.fromYmd(2026, 1, 1), .net_worth = 0, .liquid = 700, .illiquid = 0, .accounts = &.{}, .tax_types = &.{}, .source = .imported },
.{ .as_of_date = Date.fromYmd(2026, 2, 1), .net_worth = 1000, .liquid = 800, .illiquid = 200, .accounts = &.{}, .tax_types = &.{}, .source = .snapshot },
};
const out = try extractChartSeries(testing.allocator, &points, .liquid);
defer testing.allocator.free(out);
// Both rows kept; liquid is carried by imported rows.
try testing.expectEqual(@as(usize, 2), out.len);
try testing.expectEqual(@as(f64, 700), out[0].value);
try testing.expectEqual(@as(f64, 800), out[1].value);
try testing.expect(out[0].date.eql(Date.fromYmd(2026, 1, 1)));
}
test "extractChartSeries: illiquid / net_worth drop imported-only points" {
const points = [_]TimelinePoint{
.{ .as_of_date = Date.fromYmd(2026, 1, 1), .net_worth = 0, .liquid = 700, .illiquid = 0, .accounts = &.{}, .tax_types = &.{}, .source = .imported },
.{ .as_of_date = Date.fromYmd(2026, 2, 1), .net_worth = 1000, .liquid = 800, .illiquid = 200, .accounts = &.{}, .tax_types = &.{}, .source = .snapshot },
.{ .as_of_date = Date.fromYmd(2026, 3, 1), .net_worth = 1200, .liquid = 900, .illiquid = 300, .accounts = &.{}, .tax_types = &.{}, .source = .snapshot },
};
const ill = try extractChartSeries(testing.allocator, &points, .illiquid);
defer testing.allocator.free(ill);
// Imported row skipped; only the two snapshot rows remain.
try testing.expectEqual(@as(usize, 2), ill.len);
try testing.expectEqual(@as(f64, 200), ill[0].value);
try testing.expectEqual(@as(f64, 300), ill[1].value);
const nw = try extractChartSeries(testing.allocator, &points, .net_worth);
defer testing.allocator.free(nw);
try testing.expectEqual(@as(usize, 2), nw.len);
try testing.expectEqual(@as(f64, 1000), nw[0].value);
try testing.expectEqual(@as(f64, 1200), nw[1].value);
}
test "extractChartSeries: empty input returns empty slice" {
const out = try extractChartSeries(testing.allocator, &.{}, .liquid);
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 0), out.len);
}
test "extractNamedSeries accounts: matches + absent days emit 0" {
// Build three snapshots: day1 has account A; day2 has account B; day3 has both.
// Extracting "A" should see value on day1, 0 on day2, value on day3.

View file

@ -4,8 +4,8 @@
//! module. Each chart-bearing command (`quote`, `projections`)
//! has a corresponding `export*` function here that:
//!
//! 1. Calls the relevant `renderToSurface` (in `tui/chart.zig` or
//! `tui/projection_chart.zig`) to draw the chart into a z2d
//! 1. Calls the relevant `renderToSurface` (in `charts/chart.zig` or
//! `charts/projection_chart.zig`) to draw the chart into a z2d
//! `Surface`.
//! 2. Calls `z2d.png_exporter.writeToPNGFile` to land the surface
//! as a PNG file at the user-supplied path.
@ -25,12 +25,13 @@
const std = @import("std");
const z2d = @import("z2d");
const zfin = @import("root.zig");
const chart = @import("tui/chart.zig");
const projection_chart = @import("tui/projection_chart.zig");
const chart = @import("charts/chart.zig");
const projection_chart = @import("charts/projection_chart.zig");
const line_chart = @import("charts/line_chart.zig");
const projections = @import("analytics/projections.zig");
const theme = @import("tui/theme.zig");
/// Default PNG export resolution. Matches `tui/chart.zig`'s
/// Default PNG export resolution. Matches `charts/chart.zig`'s
/// `ChartConfig.max_width/max_height` defaults so an exported
/// image carries the same fidelity as a maximally-sized TUI
/// chart.
@ -91,6 +92,33 @@ pub fn exportProjectionChart(
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
}
/// Export a single-series portfolio-value timeline as a PNG. Wraps
/// `line_chart.renderToSurface` + `writeToPNGFile`. `baseline` selects
/// whether the y-axis fits the data or anchors at zero.
pub fn exportTimelineChart(
io: std.Io,
alloc: std.mem.Allocator,
points: []const line_chart.LinePoint,
baseline: line_chart.Baseline,
path: []const u8,
) !void {
var rendered = line_chart.renderToSurface(
io,
alloc,
points,
default_width,
default_height,
theme.default_theme,
.{ .baseline = baseline },
) 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" {
@ -234,3 +262,58 @@ test "exportProjectionChart returns InsufficientData with single band" {
exportProjectionChart(io, alloc, &bands, null, path),
);
}
test "exportTimelineChart writes a non-empty PNG file" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
var points: [12]line_chart.LinePoint = undefined;
for (0..12) |i| {
points[i] = .{
.date = Date.fromYmd(2025, 1, 1).addDays(@intCast(i * 30)),
.value = 1_000_000.0 + 25_000.0 * @as(f64, @floatFromInt(i)),
};
}
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_timeline.png" });
defer alloc.free(path);
try exportTimelineChart(io, alloc, &points, .fit, path);
var file = try tmp.dir.openFile(io, "test_export_timeline.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 "exportTimelineChart returns InsufficientData with a single point" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
var points: [1]line_chart.LinePoint = .{.{ .date = Date.fromYmd(2025, 1, 1), .value = 1_000_000 }};
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_timeline_insufficient.png" });
defer alloc.free(path);
try std.testing.expectError(
error.InsufficientData,
exportTimelineChart(io, alloc, &points, .fit, path),
);
}

View file

@ -5,7 +5,8 @@
const std = @import("std");
const z2d = @import("z2d");
const zfin = @import("../root.zig");
const theme = @import("theme.zig");
const theme = @import("../tui/theme.zig");
const draw = @import("draw.zig");
const Surface = z2d.Surface;
@ -196,17 +197,7 @@ pub const RenderedChart = struct {
/// Caller owns the returned slice. The surface is left intact so
/// the caller can still call `deinit`.
pub fn extractRgb(self: *const RenderedChart, alloc: std.mem.Allocator) ![]u8 {
const rgb_buf = switch (self.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const raw = try alloc.alloc(u8, rgb_buf.len * 3);
for (rgb_buf, 0..) |px, i| {
raw[i * 3 + 0] = px.r;
raw[i * 3 + 1] = px.g;
raw[i * 3 + 2] = px.b;
}
return raw;
return draw.extractRgb(alloc, &self.surface);
}
};
@ -287,14 +278,7 @@ pub fn renderToSurface(
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();
try draw.fillBackground(&ctx, fwidth, fheight, bg);
// Panel dimensions
const chart_left = margin_left;
@ -541,29 +525,14 @@ pub fn renderChart(
}
// Drawing helpers
//
// The stateless primitives below are shared with the other chart
// renderers and live in `draw.zig`; aliased here so the call sites in
// this file stay unchanged.
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);
}
/// Pre-blend a foreground color with alpha against a background color.
/// Returns a fully opaque pixel. This avoids z2d's broken src_over compositor.
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),
} };
}
/// Opaque pixel from theme color.
fn opaqueColor(c: [3]u8) Pixel {
return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } };
}
const mapY = draw.mapY;
const blendColor = draw.blendColor;
const opaqueColor = draw.opaqueColor;
const BandField = enum { upper, middle, lower };
@ -606,90 +575,12 @@ fn drawLineSeries(
ctx.setLineWidth(2.0);
}
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, w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(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, w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(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);
}
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
const drawHLine = draw.drawHLine;
const drawRect = draw.drawRect;
// Tests
test "mapY maps value to pixel coordinate" {
// value at min -> bottom
try std.testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500));
// value at max -> top
try std.testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500));
// value at midpoint -> midpoint
try std.testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500));
// flat range -> midpoint
try std.testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500));
}
test "blendColor alpha blending" {
const white = [3]u8{ 255, 255, 255 };
const black = [3]u8{ 0, 0, 0 };
// Full alpha -> foreground
const full = blendColor(white, 255, black);
try std.testing.expectEqual(@as(u8, 255), full.rgb.r);
try std.testing.expectEqual(@as(u8, 255), full.rgb.g);
// Zero alpha -> background
const zero = blendColor(white, 0, black);
try std.testing.expectEqual(@as(u8, 0), zero.rgb.r);
// Half alpha -> midpoint
const half = blendColor(white, 128, black);
// 255 * (128/255) + 0 * (127/255) 128
try std.testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129);
}
test "opaqueColor wraps theme color" {
const px = opaqueColor(.{ 0x7f, 0xd8, 0x8f });
try std.testing.expectEqual(@as(u8, 0x7f), px.rgb.r);
try std.testing.expectEqual(@as(u8, 0xd8), px.rgb.g);
try std.testing.expectEqual(@as(u8, 0x8f), px.rgb.b);
}
test "ChartConfig.parse" {
// Named modes
const auto = ChartConfig.parse("auto").?;

304
src/charts/draw.zig Normal file
View file

@ -0,0 +1,304 @@
//! Shared z2d drawing primitives for the chart renderers in
//! `src/charts/` (chart.zig, projection_chart.zig, forecast_chart.zig,
//! line_chart.zig).
//!
//! These helpers were copy-pasted verbatim across all four renderers
//! before this module existed; they're consolidated here so there is a
//! single source of truth. Everything here is a pure, stateless z2d
//! operation parameterized by pixel coordinates and pre-blended colors:
//! no theme, domain, or chart-shape knowledge lives here.
//!
//! The Surface/Context lifetime (the `Surface.init` + `errdefer`/`defer
//! deinit` dance and the AA/operator setup) deliberately stays with
//! each renderer - that ownership does not extract cleanly - so only
//! the stateless drawing belongs in this module.
const std = @import("std");
const z2d = @import("z2d");
const Surface = z2d.Surface;
const Context = z2d.Context;
const Pixel = z2d.Pixel;
/// Map a data value to a y pixel coordinate within `[top_px, bottom_px]`.
/// A larger value maps nearer `top_px` (screen space grows downward). A
/// degenerate (`min_val == max_val`) range maps to the vertical midpoint.
pub 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);
}
/// Pre-blend a foreground color with `alpha` against a background color
/// and return a fully opaque pixel. This sidesteps z2d's src_over
/// compositor (which overflows on semi-transparent fills); renderers
/// draw with the `.src` operator and pre-blend through here instead.
pub 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),
} };
}
/// Opaque pixel from an RGB triple.
pub fn opaqueColor(c: [3]u8) Pixel {
return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } };
}
/// Fill the whole `width` x `height` surface with an opaque background.
/// Mirrors the "Background" layer every renderer paints first.
pub fn fillBackground(ctx: *Context, width: f64, height: f64, bg: [3]u8) !void {
ctx.setSourceToPixel(opaqueColor(bg));
ctx.resetPath();
try ctx.moveTo(0, 0);
try ctx.lineTo(width, 0);
try ctx.lineTo(width, height);
try ctx.lineTo(0, height);
try ctx.closePath();
try ctx.fill();
}
/// Draw `n_lines - 1` evenly-spaced horizontal grid lines strictly
/// between `top` and `bottom` (the edges themselves are left to the
/// panel border). Restores the line width to 2.0 when done so callers
/// can keep drawing without re-setting it.
pub 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);
}
/// Draw a horizontal line at `y` from `x1` to `x2`. Restores the line
/// width to 2.0 afterward.
pub 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);
}
/// Draw a vertical line at `x` from `y1` to `y2`. Restores the line
/// width to 2.0 afterward.
pub fn drawVLine(ctx: *Context, x: f64, y1: f64, y2: f64, col: Pixel, line_w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w);
ctx.resetPath();
try ctx.moveTo(x, y1);
try ctx.lineTo(x, y2);
try ctx.stroke();
ctx.setLineWidth(2.0);
}
/// Stroke an axis-aligned rectangle outline. Restores the line width to
/// 2.0 afterward.
pub 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 a flat `[]u8` of R,G,B triplets from an `image_surface_rgb`.
/// Caller owns the returned slice; the surface is left intact. Every
/// renderer transmits/encodes pixels through this same shape (Kitty
/// graphics RGB and z2d PNG export both want tightly-packed RGB bytes).
pub fn extractRgb(alloc: std.mem.Allocator, sfc: *const Surface) ![]u8 {
const rgb_buf = switch (sfc.*) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const raw = try alloc.alloc(u8, rgb_buf.len * 3);
for (rgb_buf, 0..) |px, i| {
raw[i * 3 + 0] = px.r;
raw[i * 3 + 1] = px.g;
raw[i * 3 + 2] = px.b;
}
return raw;
}
// Tests
const testing = std.testing;
/// Build a fresh RGB drawing context backed by `sfc`, configured the way
/// every renderer configures it (AA off, `.src` operator). Caller owns
/// both and must `ctx.deinit()` / `sfc.deinit(alloc)`.
fn testContext(sfc: *Surface) Context {
var ctx = Context.init(testing.io, testing.allocator, sfc);
ctx.setAntiAliasingMode(.none);
ctx.setOperator(.src);
return ctx;
}
/// True if any pixel in the surface exactly matches the given RGB.
fn surfaceHasColor(sfc: *const Surface, r: u8, g: u8, b: u8) bool {
const buf = switch (sfc.*) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
for (buf) |px| {
if (px.r == r and px.g == g and px.b == b) return true;
}
return false;
}
test "mapY maps value to pixel coordinate" {
// value at min -> bottom
try testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500));
// value at max -> top
try testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500));
// value at midpoint -> midpoint
try testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500));
// flat range -> midpoint
try testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500));
}
test "blendColor alpha blending" {
const white = [3]u8{ 255, 255, 255 };
const black = [3]u8{ 0, 0, 0 };
// Full alpha -> foreground.
const full = blendColor(white, 255, black);
try testing.expectEqual(@as(u8, 255), full.rgb.r);
try testing.expectEqual(@as(u8, 255), full.rgb.g);
try testing.expectEqual(@as(u8, 255), full.rgb.b);
// Zero alpha -> background.
const zero = blendColor(white, 0, black);
try testing.expectEqual(@as(u8, 0), zero.rgb.r);
// Half alpha -> midpoint (255 * 128/255 ~= 128).
const half = blendColor(white, 128, black);
try testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129);
// Zero alpha blends toward a non-black background, not just black.
const onto_gray = blendColor(white, 0, .{ 40, 50, 60 });
try testing.expectEqual(@as(u8, 40), onto_gray.rgb.r);
try testing.expectEqual(@as(u8, 50), onto_gray.rgb.g);
try testing.expectEqual(@as(u8, 60), onto_gray.rgb.b);
}
test "opaqueColor wraps an RGB triple" {
const px = opaqueColor(.{ 0x7f, 0xd8, 0x8f });
try testing.expectEqual(@as(u8, 0x7f), px.rgb.r);
try testing.expectEqual(@as(u8, 0xd8), px.rgb.g);
try testing.expectEqual(@as(u8, 0x8f), px.rgb.b);
}
test "fillBackground paints every pixel the bg color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 8, 6);
defer sfc.deinit(alloc);
var ctx = testContext(&sfc);
defer ctx.deinit();
try fillBackground(&ctx, 8, 6, .{ 0x11, 0x22, 0x33 });
const buf = switch (sfc) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
for (buf) |px| {
try testing.expectEqual(@as(u8, 0x11), px.r);
try testing.expectEqual(@as(u8, 0x22), px.g);
try testing.expectEqual(@as(u8, 0x33), px.b);
}
}
test "extractRgb yields 3 interleaved bytes per pixel" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 4, 4);
defer sfc.deinit(alloc);
var ctx = testContext(&sfc);
defer ctx.deinit();
try fillBackground(&ctx, 4, 4, .{ 0xde, 0xad, 0xbe });
const raw = try extractRgb(alloc, &sfc);
defer alloc.free(raw);
const buf = switch (sfc) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
try testing.expectEqual(buf.len * 3, raw.len);
// First pixel round-trips as (R, G, B) at indices 0, 1, 2.
try testing.expectEqual(@as(u8, 0xde), raw[0]);
try testing.expectEqual(@as(u8, 0xad), raw[1]);
try testing.expectEqual(@as(u8, 0xbe), raw[2]);
}
test "drawHLine strokes a line in the requested color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 24);
defer sfc.deinit(alloc);
var ctx = testContext(&sfc);
defer ctx.deinit();
try fillBackground(&ctx, 40, 24, .{ 0, 0, 0 });
try drawHLine(&ctx, 2, 38, 12, opaqueColor(.{ 0xff, 0x00, 0x00 }), 1.0);
try testing.expect(surfaceHasColor(&sfc, 0xff, 0x00, 0x00));
}
test "drawVLine strokes a line in the requested color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 24);
defer sfc.deinit(alloc);
var ctx = testContext(&sfc);
defer ctx.deinit();
try fillBackground(&ctx, 40, 24, .{ 0, 0, 0 });
try drawVLine(&ctx, 20, 2, 22, opaqueColor(.{ 0x00, 0xff, 0x00 }), 1.0);
try testing.expect(surfaceHasColor(&sfc, 0x00, 0xff, 0x00));
}
test "drawRect strokes a rectangle outline in the requested color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 24);
defer sfc.deinit(alloc);
var ctx = testContext(&sfc);
defer ctx.deinit();
try fillBackground(&ctx, 40, 24, .{ 0, 0, 0 });
try drawRect(&ctx, 4, 4, 36, 20, opaqueColor(.{ 0x00, 0x00, 0xff }), 1.0);
try testing.expect(surfaceHasColor(&sfc, 0x00, 0x00, 0xff));
}
test "drawHorizontalGridLines strokes lines in the requested color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 40);
defer sfc.deinit(alloc);
var ctx = testContext(&sfc);
defer ctx.deinit();
try fillBackground(&ctx, 40, 40, .{ 0, 0, 0 });
try drawHorizontalGridLines(&ctx, 2, 38, 2, 38, 5, opaqueColor(.{ 0x33, 0x66, 0x99 }));
try testing.expect(surfaceHasColor(&sfc, 0x33, 0x66, 0x99));
}

View file

@ -27,9 +27,10 @@
const std = @import("std");
const z2d = @import("z2d");
const theme = @import("theme.zig");
const theme = @import("../tui/theme.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const Date = @import("../Date.zig");
const draw = @import("draw.zig");
const Surface = z2d.Surface;
const Context = z2d.Context;
@ -92,14 +93,7 @@ pub fn renderConvergenceChart(
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();
try draw.fillBackground(&ctx, fwidth, fheight, bg);
const chart_left = margin_left;
const chart_right = fwidth - margin_right;
@ -244,14 +238,7 @@ pub fn renderBacktestChart(
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();
try draw.fillBackground(&ctx, fwidth, fheight, bg);
const chart_left = margin_left;
const chart_right = fwidth - margin_right;
@ -492,90 +479,20 @@ fn fillCircle(ctx: *Context, cx: f64, cy: f64, r: f64) !void {
try ctx.fill();
}
// Shared helpers (mirrors of projection_chart's privates)
// Shared helpers
//
// The stateless primitives below are shared with the other chart
// renderers and live in `draw.zig`; aliased here so the call sites in
// this file stay unchanged. The chart-specific helpers above
// (drawSeries/strokeSegment/drawDashedLine/fillCircle) stay local.
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;
}
const mapY = draw.mapY;
const blendColor = draw.blendColor;
const opaqueColor = draw.opaqueColor;
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
const drawHLine = draw.drawHLine;
const drawRect = draw.drawRect;
const extractRgb = draw.extractRgb;
// Tests

402
src/charts/line_chart.zig Normal file
View file

@ -0,0 +1,402 @@
//! Single-series line chart renderer using z2d.
//!
//! A deliberately slim sibling of `projection_chart.zig`: it draws one
//! value-over-time series (e.g. portfolio Liquid / Net Worth) as a
//! filled, trend-colored line - no percentile bands, no Bollinger, no
//! RSI, no volume. It exists so the `history` command can render its
//! portfolio-value timeline as a real bitmap chart (inline Kitty
//! graphics or a PNG via `--export-chart`) instead of only braille,
//! reusing the same `renderToSurface` -> RGB / PNG seam the other
//! charts use.
//!
//! Visual layers (bottom to top):
//! - Background
//! - Horizontal grid lines
//! - Filled area under the line (faint, trend-colored)
//! - Zero reference line (only when the y-range straddles zero)
//! - The value line (solid, trend-colored: green if the series ended
//! above where it started, red otherwise)
//! - Panel border
//!
//! X positions are date-proportional: each point sits at
//! `(date - first_date) / (last_date - first_date)` across the chart
//! width, so irregular snapshot spacing (daily recent, weekly/monthly
//! for older imported history) renders to scale rather than evenly by
//! index. If every point shares a date (degenerate input) the renderer
//! falls back to even index spacing.
const std = @import("std");
const z2d = @import("z2d");
const theme = @import("../tui/theme.zig");
const Date = @import("../Date.zig");
const draw = @import("draw.zig");
const Surface = z2d.Surface;
const Context = z2d.Context;
/// Margins in pixels.
const margin_left: f64 = 4;
const margin_right: f64 = 4;
const margin_top: f64 = 4;
const margin_bottom: f64 = 4;
/// How the y-axis lower bound is chosen.
pub const Baseline = enum {
/// Fit the data: lower bound is the series minimum minus a small
/// pad. Best for reading week-to-week variation in a large,
/// slowly-moving balance.
fit,
/// Anchor the lower bound at zero (clamped to the data minimum if
/// the series itself dips below zero). Shows absolute scale at the
/// cost of flattening the visible variation.
zero,
};
/// One (date, value) point on the series. Leaf-level: the chart module
/// deliberately does not depend on `analytics/timeline.zig`. Callers
/// convert their domain points (e.g. `timeline.MetricPoint`) into this
/// shape - same fields, distinct type - the way `projection_chart`
/// keeps its own `ActualsPoint`.
pub const LinePoint = struct {
date: Date,
value: f64,
};
/// Render options.
pub const Options = struct {
baseline: Baseline = .fit,
};
/// Line chart render result (raw RGB), produced by `renderLineChart`.
pub const LineChartResult = struct {
/// Raw RGB pixel data (3 bytes per pixel, row-major).
rgb_data: []const u8,
width: u16,
height: u16,
/// Value range for external label rendering.
value_min: f64,
value_max: f64,
};
/// Owned by the caller - call `result.deinit(alloc)` after using it.
/// The shared mid-stage between RGB extraction (Kitty graphics) and PNG
/// export (`--export-chart`). See `renderToSurface`.
pub const RenderedLineChart = struct {
surface: Surface,
width: u16,
height: u16,
value_min: f64,
value_max: f64,
pub fn deinit(self: *RenderedLineChart, alloc: std.mem.Allocator) void {
self.surface.deinit(alloc);
self.* = undefined;
}
/// Extract a flat []u8 of R,G,B triplets from the surface buffer.
/// Caller owns the returned slice. The surface is left intact.
pub fn extractRgb(self: *const RenderedLineChart, alloc: std.mem.Allocator) ![]u8 {
return draw.extractRgb(alloc, &self.surface);
}
};
/// Render a single-series line chart into a `Surface` and return both.
/// Caller owns the result and must call `deinit`.
///
/// Two consumers:
/// - `renderLineChart` wraps this for the CLI's inline Kitty graphics
/// path (extracts RGB, frees surface).
/// - `chart_export.exportTimelineChart` wraps this for PNG export via
/// `z2d.png_exporter.writeToPNGFile`.
pub fn renderToSurface(
io: std.Io,
alloc: std.mem.Allocator,
points: []const LinePoint,
width_px: u32,
height_px: u32,
th: theme.Theme,
opts: Options,
) !RenderedLineChart {
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);
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);
// Background
try draw.fillBackground(&ctx, fwidth, fheight, bg);
// Chart area
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;
// Value range
var data_min: f64 = points[0].value;
var data_max: f64 = points[0].value;
for (points) |p| {
if (p.value < data_min) data_min = p.value;
if (p.value > data_max) data_max = p.value;
}
const pad = (data_max - data_min) * 0.05;
const value_max: f64 = data_max + pad;
const value_min: f64 = switch (opts.baseline) {
.fit => data_min - pad,
// Anchor at zero, but never crop a series that genuinely dips
// below zero (e.g. a negative net worth).
.zero => @min(0, data_min),
};
// X mapping (date-proportional)
const first_days: f64 = @floatFromInt(points[0].date.days);
const last_days: f64 = @floatFromInt(points[points.len - 1].date.days);
const span_days: f64 = last_days - first_days;
// Degenerate input (all points share a date): fall back to even
// index spacing so we still draw something sensible.
const use_dates = span_days > 0;
const index_step = chart_w / @as(f64, @floatFromInt(points.len - 1));
const mapX = struct {
fn at(i: usize, p: LinePoint, left: f64, cw: f64, fd: f64, span: f64, by_date: bool, istep: f64) f64 {
if (by_date) {
const d: f64 = @floatFromInt(p.date.days);
return left + ((d - fd) / span) * cw;
}
return left + @as(f64, @floatFromInt(i)) * istep;
}
}.at;
// 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);
// Filled area under the line (faint, trend-colored)
const trend_up = points[points.len - 1].value >= points[0].value;
const trend_color = if (trend_up) th.positive else th.negative;
{
const fill_color = blendColor(trend_color, 30, bg);
ctx.setSourceToPixel(fill_color);
ctx.resetPath();
for (points, 0..) |p, i| {
const x = mapX(i, p, chart_left, chart_w, first_days, span_days, use_dates, index_step);
const y = mapY(p.value, value_min, value_max, chart_top, chart_bottom);
if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
}
const last_x = mapX(points.len - 1, points[points.len - 1], chart_left, chart_w, first_days, span_days, use_dates, index_step);
try ctx.lineTo(last_x, chart_bottom);
try ctx.lineTo(chart_left, chart_bottom);
try ctx.closePath();
try ctx.fill();
}
// Zero reference line (only when the range straddles zero)
if (value_min < 0 and value_max > 0) {
const zero_y = mapY(0, value_min, value_max, chart_top, chart_bottom);
const zero_color = blendColor(th.text_muted, 100, bg);
try drawHLine(&ctx, chart_left, chart_right, zero_y, zero_color, 1.0);
}
// Value line (solid, trend-colored, on top)
{
ctx.setSourceToPixel(opaqueColor(trend_color));
ctx.setLineWidth(2.0);
ctx.resetPath();
for (points, 0..) |p, i| {
const x = mapX(i, p, chart_left, chart_w, first_days, span_days, use_dates, index_step);
const y = mapY(p.value, 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();
}
// Panel border
{
const border_color = blendColor(th.border, 80, bg);
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, border_color, 1.0);
}
return .{
.surface = sfc,
.width = @intCast(width_px),
.height = @intCast(height_px),
.value_min = value_min,
.value_max = value_max,
};
}
/// Render a single-series line chart to raw RGB pixel data. The returned
/// `rgb_data` is allocated with `alloc` and must be freed by the caller.
pub fn renderLineChart(
io: std.Io,
alloc: std.mem.Allocator,
points: []const LinePoint,
width_px: u32,
height_px: u32,
th: theme.Theme,
opts: Options,
) !LineChartResult {
var rendered = try renderToSurface(io, alloc, points, width_px, height_px, th, opts);
defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc);
return .{
.rgb_data = raw,
.width = rendered.width,
.height = rendered.height,
.value_min = rendered.value_min,
.value_max = rendered.value_max,
};
}
// Drawing helpers
//
// The stateless primitives below are shared with the other chart
// renderers and live in `draw.zig`; aliased here so the call sites in
// this file stay unchanged.
const mapY = draw.mapY;
const blendColor = draw.blendColor;
const opaqueColor = draw.opaqueColor;
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
const drawHLine = draw.drawHLine;
const drawRect = draw.drawRect;
// Tests
const test_th = theme.default_theme;
fn pt(y: i16, m: u8, d: u8, v: f64) LinePoint {
return .{ .date = Date.fromYmd(y, m, d), .value = v };
}
test "renderToSurface returns InsufficientData with < 2 points" {
const alloc = std.testing.allocator;
const one = [_]LinePoint{pt(2026, 1, 1, 100)};
try std.testing.expectError(error.InsufficientData, renderToSurface(std.testing.io, alloc, &one, 200, 100, test_th, .{}));
const none = [_]LinePoint{};
try std.testing.expectError(error.InsufficientData, renderToSurface(std.testing.io, alloc, &none, 200, 100, test_th, .{}));
}
test "renderToSurface returns a populated RGB surface at requested dimensions" {
const alloc = std.testing.allocator;
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 120), pt(2026, 3, 1, 110) };
var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{});
defer rendered.deinit(alloc);
try std.testing.expectEqual(@as(u16, 200), rendered.width);
try std.testing.expectEqual(@as(u16, 100), rendered.height);
switch (rendered.surface) {
.image_surface_rgb => {},
else => try std.testing.expect(false),
}
}
test "renderToSurface fills background with theme bg" {
const alloc = std.testing.allocator;
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 120) };
var th = theme.default_theme;
th.bg = .{ 0xab, 0xcd, 0xef };
var rendered = try renderToSurface(std.testing.io, alloc, &points, 100, 50, th, .{});
defer rendered.deinit(alloc);
const buf = switch (rendered.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
// Pixel (0,0) is in the top-left margin - outside the plotted area.
try std.testing.expectEqual(@as(u8, 0xab), buf[0].r);
try std.testing.expectEqual(@as(u8, 0xcd), buf[0].g);
try std.testing.expectEqual(@as(u8, 0xef), buf[0].b);
}
test "renderToSurface fit baseline keeps value_min at or below the data minimum" {
const alloc = std.testing.allocator;
// All values well above zero; fit must NOT anchor at zero.
const points = [_]LinePoint{ pt(2026, 1, 1, 1_000_000), pt(2026, 2, 1, 1_050_000), pt(2026, 3, 1, 1_020_000) };
var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{ .baseline = .fit });
defer rendered.deinit(alloc);
try std.testing.expect(rendered.value_min <= 1_000_000);
try std.testing.expect(rendered.value_min > 0); // far from zero
try std.testing.expect(rendered.value_max >= 1_050_000);
}
test "renderToSurface zero baseline anchors value_min at zero for positive data" {
const alloc = std.testing.allocator;
const points = [_]LinePoint{ pt(2026, 1, 1, 1_000_000), pt(2026, 2, 1, 1_050_000) };
var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{ .baseline = .zero });
defer rendered.deinit(alloc);
try std.testing.expectEqual(@as(f64, 0), rendered.value_min);
try std.testing.expect(rendered.value_max > 1_000_000);
}
test "renderToSurface zero baseline still includes a negative data minimum" {
const alloc = std.testing.allocator;
// Net worth dips below zero - zero baseline must not crop it.
const points = [_]LinePoint{ pt(2026, 1, 1, -50_000), pt(2026, 2, 1, 10_000) };
var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{ .baseline = .zero });
defer rendered.deinit(alloc);
try std.testing.expect(rendered.value_min <= -50_000);
}
test "renderToSurface is deterministic across calls with same input" {
const alloc = std.testing.allocator;
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 140), pt(2026, 3, 1, 90) };
var a = try renderToSurface(std.testing.io, alloc, &points, 120, 60, test_th, .{});
defer a.deinit(alloc);
var b = try renderToSurface(std.testing.io, alloc, &points, 120, 60, test_th, .{});
defer b.deinit(alloc);
const buf_a = switch (a.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const buf_b = switch (b.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
try std.testing.expectEqual(buf_a.len, buf_b.len);
var i: usize = 0;
while (i < buf_a.len) : (i += 50) {
try std.testing.expectEqual(buf_a[i].r, buf_b[i].r);
try std.testing.expectEqual(buf_a[i].g, buf_b[i].g);
try std.testing.expectEqual(buf_a[i].b, buf_b[i].b);
}
}
test "renderToSurface handles degenerate single-date input without crashing" {
const alloc = std.testing.allocator;
// Both points share a date -> span is zero -> index-spacing fallback.
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 1, 1, 120) };
var rendered = try renderToSurface(std.testing.io, alloc, &points, 100, 50, test_th, .{});
defer rendered.deinit(alloc);
try std.testing.expectEqual(@as(u16, 100), rendered.width);
}
test "renderLineChart wraps renderToSurface and produces RGB triplets" {
const alloc = std.testing.allocator;
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 120), pt(2026, 3, 1, 110) };
const result = try renderLineChart(std.testing.io, alloc, &points, 50, 40, test_th, .{});
defer alloc.free(result.rgb_data);
try std.testing.expectEqual(@as(u16, 50), result.width);
try std.testing.expectEqual(@as(u16, 40), result.height);
try std.testing.expectEqual(@as(usize, 50 * 40 * 3), result.rgb_data.len);
try std.testing.expect(result.value_max > result.value_min);
}

View file

@ -16,12 +16,12 @@
const std = @import("std");
const z2d = @import("z2d");
const theme = @import("theme.zig");
const theme = @import("../tui/theme.zig");
const projections = @import("../analytics/projections.zig");
const draw = @import("draw.zig");
const Surface = z2d.Surface;
const Context = z2d.Context;
const Pixel = z2d.Pixel;
/// Margins in pixels.
const margin_left: f64 = 4;
@ -77,17 +77,7 @@ pub const RenderedProjection = struct {
/// Extract a flat []u8 of R,G,B triplets from the surface buffer.
/// Caller owns the returned slice. The surface is left intact.
pub fn extractRgb(self: *const RenderedProjection, alloc: std.mem.Allocator) ![]u8 {
const rgb_buf = switch (self.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const raw = try alloc.alloc(u8, rgb_buf.len * 3);
for (rgb_buf, 0..) |px, i| {
raw[i * 3 + 0] = px.r;
raw[i * 3 + 1] = px.g;
raw[i * 3 + 2] = px.b;
}
return raw;
return draw.extractRgb(alloc, &self.surface);
}
};
@ -126,14 +116,7 @@ pub fn renderToSurface(
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();
try draw.fillBackground(&ctx, fwidth, fheight, bg);
// Chart area
const chart_left = margin_left;
@ -354,105 +337,21 @@ pub fn renderProjectionChart(
}
// Drawing helpers
//
// The stateless primitives below are shared with the other chart
// renderers and live in `draw.zig`; aliased here so the call sites in
// this file stay unchanged.
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 drawVLine(ctx: *Context, x: f64, y1: f64, y2: f64, col: Pixel, line_w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w);
ctx.resetPath();
try ctx.moveTo(x, y1);
try ctx.lineTo(x, y2);
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);
}
const mapY = draw.mapY;
const blendColor = draw.blendColor;
const opaqueColor = draw.opaqueColor;
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
const drawHLine = draw.drawHLine;
const drawVLine = draw.drawVLine;
const drawRect = draw.drawRect;
// Tests
test "mapY maps value to pixel coordinate" {
try std.testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500));
try std.testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500));
try std.testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500));
try std.testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500));
}
test "blendColor alpha blending" {
const white = [3]u8{ 255, 255, 255 };
const black = [3]u8{ 0, 0, 0 };
const full = blendColor(white, 255, black);
try std.testing.expectEqual(@as(u8, 255), full.rgb.r);
const zero = blendColor(white, 0, black);
try std.testing.expectEqual(@as(u8, 0), zero.rgb.r);
const half = blendColor(white, 128, black);
try std.testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129);
}
test "renderProjectionChart produces valid output" {
const alloc = std.testing.allocator;
const bands = [_]projections.YearPercentiles{
@ -461,7 +360,7 @@ test "renderProjectionChart produces valid output" {
.{ .year = 20, .p10 = 3000000, .p25 = 9000000, .p50 = 18000000, .p75 = 30000000, .p90 = 50000000 },
};
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
defer alloc.free(result.rgb_data);
@ -477,7 +376,7 @@ test "renderProjectionChart insufficient data" {
.{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 },
};
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
try std.testing.expectError(error.InsufficientData, result);
}
@ -496,7 +395,7 @@ test "renderProjectionChart with overlay produces valid output" {
};
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
defer alloc.free(result.rgb_data);
@ -519,7 +418,7 @@ test "renderProjectionChart overlay expands y-range when actuals exceed bands" {
};
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
defer alloc.free(result.rgb_data);
@ -536,7 +435,7 @@ test "renderProjectionChart overlay with no points renders without crash" {
};
const overlay: ActualsOverlay = .{ .points = &.{}, .today_years = 0.5 };
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
defer alloc.free(result.rgb_data);
try std.testing.expect(result.rgb_data.len > 0);
@ -554,7 +453,7 @@ test "renderToSurface returns a populated RGB surface at requested dimensions" {
.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
};
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null);
defer rendered.deinit(alloc);
@ -572,7 +471,7 @@ test "renderToSurface fills background with theme bg" {
.{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 },
.{ .year = 1, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 },
};
var th = @import("theme.zig").default_theme;
var th = @import("../tui/theme.zig").default_theme;
th.bg = .{ 0xab, 0xcd, 0xef };
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null);
@ -594,7 +493,7 @@ test "renderToSurface is deterministic across calls with same input" {
.{ .year = 5, .p10 = 90, .p25 = 110, .p50 = 130, .p75 = 160, .p90 = 200 },
.{ .year = 10, .p10 = 80, .p25 = 120, .p50 = 160, .p75 = 220, .p90 = 300 },
};
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
defer a.deinit(alloc);
@ -624,7 +523,7 @@ test "RenderedProjection.extractRgb produces 3 bytes per pixel" {
.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
};
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null);
defer rendered.deinit(alloc);
@ -649,7 +548,7 @@ test "renderToSurface clamps value_min to zero when bands include negatives" {
.{ .year = 0, .p10 = -100, .p25 = -50, .p50 = 0, .p75 = 50, .p90 = 100 },
.{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 },
};
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
defer rendered.deinit(alloc);

View file

@ -665,16 +665,16 @@ pub fn runBands(
// Translate the view-layer overlay points (if any) into the
// chart-module's ActualsPoint shape. Same conversion the TUI
// does in `projections_tab.drawWithKittyChart`.
var overlay_buf: ?[]@import("../tui/projection_chart.zig").ActualsPoint = null;
var overlay_buf: ?[]@import("../charts/projection_chart.zig").ActualsPoint = null;
defer if (overlay_buf) |ob| va.free(ob);
const overlay_input = blk: {
const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null);
const buf = va.alloc(@import("../tui/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null);
const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../charts/projection_chart.zig").ActualsOverlay, null);
const buf = va.alloc(@import("../charts/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../charts/projection_chart.zig").ActualsOverlay, null);
for (ov.points, 0..) |p, i| {
buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
}
overlay_buf = buf;
break :blk @import("../tui/projection_chart.zig").ActualsOverlay{
break :blk @import("../charts/projection_chart.zig").ActualsOverlay{
.points = buf,
.today_years = ov.today_years,
};

View file

@ -5,7 +5,7 @@ const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const chart_export = @import("../chart_export.zig");
const tui_chart = @import("../tui/chart.zig");
const tui_chart = @import("../charts/chart.zig");
pub const ParsedArgs = struct {
symbol: []const u8,

View file

@ -9,7 +9,7 @@ const keybinds = @import("tui/keybinds.zig");
const tab_framework = @import("tui/tab_framework.zig");
const framework = @import("commands/framework.zig");
const theme = @import("tui/theme.zig");
const chart = @import("tui/chart.zig");
const chart = @import("charts/chart.zig");
const input_buffer = @import("tui/input_buffer.zig");
pub const PortfolioData = @import("PortfolioData.zig");

View file

@ -26,8 +26,8 @@ const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const projection_chart = @import("projection_chart.zig");
const forecast_chart = @import("forecast_chart.zig");
const projection_chart = @import("../charts/projection_chart.zig");
const forecast_chart = @import("../charts/forecast_chart.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const imported = @import("../data/imported_values.zig");
const milestones = @import("../analytics/milestones.zig");
@ -159,7 +159,7 @@ pub const State = struct {
/// view (`.bands`) renders the standard percentile-band chart
/// + projection report. `.convergence` and `.return_backtest`
/// pull data from `imported_values.srf` and render
/// forecast-evaluation charts via `tui/forecast_chart.zig`.
/// forecast-evaluation charts via `charts/forecast_chart.zig`.
/// Toggled by the `c` and `r` keybinds; toggling either
/// clears the other (mutually exclusive).
sub_view: SubView = .bands,

View file

@ -4,7 +4,7 @@ const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const theme = @import("theme.zig");
const chart = @import("chart.zig");
const chart = @import("../charts/chart.zig");
const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");