diff --git a/src/analytics/indicators.zig b/src/analytics/indicators.zig index 03497af..0b3c8aa 100644 --- a/src/analytics/indicators.zig +++ b/src/analytics/indicators.zig @@ -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 { diff --git a/src/analytics/timeline.zig b/src/analytics/timeline.zig index de83f35..be50c7f 100644 --- a/src/analytics/timeline.zig +++ b/src/analytics/timeline.zig @@ -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. diff --git a/src/chart_export.zig b/src/chart_export.zig index effdc94..5e2735e 100644 --- a/src/chart_export.zig +++ b/src/chart_export.zig @@ -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), + ); +} diff --git a/src/tui/chart.zig b/src/charts/chart.zig similarity index 87% rename from src/tui/chart.zig rename to src/charts/chart.zig index a62a564..4d038a0 100644 --- a/src/tui/chart.zig +++ b/src/charts/chart.zig @@ -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").?; diff --git a/src/charts/draw.zig b/src/charts/draw.zig new file mode 100644 index 0000000..2a7e930 --- /dev/null +++ b/src/charts/draw.zig @@ -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)); +} diff --git a/src/tui/forecast_chart.zig b/src/charts/forecast_chart.zig similarity index 86% rename from src/tui/forecast_chart.zig rename to src/charts/forecast_chart.zig index 24416db..6ea7ae0 100644 --- a/src/tui/forecast_chart.zig +++ b/src/charts/forecast_chart.zig @@ -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 ───────────────────────────────────────────────────── diff --git a/src/charts/line_chart.zig b/src/charts/line_chart.zig new file mode 100644 index 0000000..9100704 --- /dev/null +++ b/src/charts/line_chart.zig @@ -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); +} diff --git a/src/tui/projection_chart.zig b/src/charts/projection_chart.zig similarity index 83% rename from src/tui/projection_chart.zig rename to src/charts/projection_chart.zig index 2a73321..f24fb30 100644 --- a/src/tui/projection_chart.zig +++ b/src/charts/projection_chart.zig @@ -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); diff --git a/src/commands/projections.zig b/src/commands/projections.zig index da0521b..fc5bd2d 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -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, }; diff --git a/src/commands/quote.zig b/src/commands/quote.zig index 90360d5..49002c4 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -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, diff --git a/src/tui.zig b/src/tui.zig index 9cd7da1..42cb869 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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"); diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 606606f..1d0854d 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.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, diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index d5339a6..a167601 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -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");