centralize charting/extract shared helpers, prepare for kitty-chart history
This commit is contained in:
parent
5a2b29fdd4
commit
e6ec5fdac1
13 changed files with 929 additions and 365 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
304
src/charts/draw.zig
Normal 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));
|
||||
}
|
||||
|
|
@ -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
402
src/charts/line_chart.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue