diff --git a/src/chart_export.zig b/src/chart_export.zig index 5e2735e..de275ab 100644 --- a/src/chart_export.zig +++ b/src/chart_export.zig @@ -56,6 +56,7 @@ pub fn exportSymbolChart( default_height, theme.default_theme, null, + true, ) catch |err| switch (err) { error.InsufficientData => return error.InsufficientData, else => return err, @@ -83,6 +84,7 @@ pub fn exportProjectionChart( default_height, theme.default_theme, actuals, + true, ) catch |err| switch (err) { error.InsufficientData => return error.InsufficientData, else => return err, @@ -109,7 +111,7 @@ pub fn exportTimelineChart( default_width, default_height, theme.default_theme, - .{ .baseline = baseline }, + .{ .baseline = baseline, .axis_labels = true }, ) catch |err| switch (err) { error.InsufficientData => return error.InsufficientData, else => return err, diff --git a/src/charts/axis.zig b/src/charts/axis.zig new file mode 100644 index 0000000..e9bc288 --- /dev/null +++ b/src/charts/axis.zig @@ -0,0 +1,157 @@ +//! Shared axis-label helpers for the chart exports, layered on top of +//! `text.zig` (bitmap-glyph stamping) and `format.fmtLargeNum` (compact +//! dollar formatting). +//! +//! Each renderer reserves margins and calls these to draw y-axis dollar +//! ticks and x-axis endpoint labels straight into the surface buffer. +//! Kept here (rather than copy-pasted into each chart) so there's one +//! source of truth for label formatting, spacing, and tick math. + +const std = @import("std"); +const z2d = @import("z2d"); +const text = @import("text.zig"); +const draw = @import("draw.zig"); +const fmt = @import("../format.zig"); + +const Surface = z2d.Surface; + +/// Pick a glyph scale from the surface height so labels stay legible on +/// large exports (1080p -> 3) and shrink on small surfaces (>= 1). +pub fn labelScale(height_px: i32) i32 { + return @max(1, @divFloor(height_px, 360)); +} + +/// Glyph cell height in surface pixels at `scale`. +pub fn charHeight(scale: i32) f64 { + return @floatFromInt(text.glyph_h * scale); +} + +/// Gap (pixels) between the plot edge and the nearest label. +pub fn labelGap(scale: i32) f64 { + return @floatFromInt(4 * scale); +} + +/// Bottom margin to reserve so a full x-axis label row fits below the +/// plot without clipping: gap + glyph height + a little breathing room. +pub fn bottomMargin(scale: i32) f64 { + return labelGap(scale) + charHeight(scale) + @as(f64, @floatFromInt(2 * scale)); +} + +/// Left/right margin (in pixels) to reserve for a column of dollar +/// labels at `scale`. Sized for the widest label we emit - an 8-glyph +/// comma'd sub-million value like "$999,999" - plus the gap to the plot. +pub fn yAxisMargin(scale: i32) f64 { + return @as(f64, @floatFromInt(text.measureWidth("$999,999", scale))) + labelGap(scale) + @as(f64, @floatFromInt(2 * scale)); +} + +/// Format a dollar value for an axis tick. Below a million we use whole +/// dollars with thousands separators ("$887,889"); at or above a million +/// we switch to compact T/B/M suffixes via `format.fmtLargeNum` +/// ("$1.3M", "$370.2M") so large axes stay narrow. Negatives get a +/// leading "-". The sub-million path rounds to the nearest whole dollar. +pub fn fmtDollar(buf: []u8, value: f64) []const u8 { + const sign = if (value < 0) "-" else ""; + const abs = @abs(value); + if (abs >= 1_000_000) { + const large = fmt.fmtLargeNum(abs); + const trimmed = std.mem.trimEnd(u8, &large, " "); + return std.fmt.bufPrint(buf, "{s}${s}", .{ sign, trimmed }) catch "$?"; + } + var nbuf: [20]u8 = undefined; + const whole: u64 = @intFromFloat(@round(abs)); + const commas = fmt.fmtIntCommas(&nbuf, whole); + return std.fmt.bufPrint(buf, "{s}${s}", .{ sign, commas }) catch "$?"; +} + +/// Draw `n + 1` right-aligned dollar labels evenly spaced from +/// `value_max` (at `top`) down to `value_min` (at `bottom`), with each +/// label's right edge a small pad left of `right_x` (typically the +/// plot's left edge) and vertically centered on its level. +pub fn drawYDollarTicks( + sfc: *Surface, + scale: i32, + color: [3]u8, + right_x: f64, + top: f64, + bottom: f64, + value_min: f64, + value_max: f64, + n: usize, +) void { + const range = value_max - value_min; + const span = bottom - top; + const half_h = @divFloor(text.glyph_h * scale, 2); + const pad: f64 = labelGap(scale); + var i: usize = 0; + while (i <= n) : (i += 1) { + const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n)); + const val = value_max - frac * range; + const y = top + frac * span; + var buf: [24]u8 = undefined; + const label = fmtDollar(&buf, val); + const lw = text.measureWidth(label, scale); + const lx = @as(i32, @intFromFloat(right_x - pad)) - lw; + const ly = @as(i32, @intFromFloat(y)) - half_h; + text.drawText(sfc, lx, ly, scale, color, label); + } +} + +/// Draw two endpoint labels on baseline `y`: `left_label` left-aligned +/// at `left`, `right_label` right-aligned ending at `right`. Used for +/// x-axis start/end (dates for time series, offsets for projections). +pub fn drawXEndpoints( + sfc: *Surface, + scale: i32, + color: [3]u8, + left: f64, + right: f64, + y: f64, + left_label: []const u8, + right_label: []const u8, +) void { + const yi: i32 = @intFromFloat(y); + text.drawText(sfc, @as(i32, @intFromFloat(left)), yi, scale, color, left_label); + const rw = text.measureWidth(right_label, scale); + text.drawText(sfc, @as(i32, @intFromFloat(right)) - rw, yi, scale, color, right_label); +} + +// ── Tests ───────────────────────────────────────────────────────────── + +const testing = std.testing; + +test "labelScale grows with height, floored at 1" { + try testing.expectEqual(@as(i32, 1), labelScale(100)); + try testing.expectEqual(@as(i32, 1), labelScale(360)); + try testing.expectEqual(@as(i32, 3), labelScale(1080)); +} + +test "fmtDollar: M/B suffixes at/above a million, commas below, sign for negatives" { + var buf: [24]u8 = undefined; + try testing.expectEqualStrings("$1.3M", fmtDollar(&buf, 1_250_000)); + try testing.expectEqualStrings("$2.0B", fmtDollar(&buf, 2_000_000_000)); + try testing.expectEqualStrings("$950,000", fmtDollar(&buf, 950_000)); + try testing.expectEqualStrings("$887,889", fmtDollar(&buf, 887_889)); + try testing.expectEqualStrings("$313", fmtDollar(&buf, 313)); + try testing.expectEqualStrings("-$1.2M", fmtDollar(&buf, -1_200_000)); +} + +test "drawYDollarTicks stamps labels in the requested color" { + const alloc = testing.allocator; + var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 200); + defer sfc.deinit(alloc); + const color = [3]u8{ 0xCC, 0xCC, 0xCC }; + try testing.expectEqual(@as(usize, 0), draw.countColor(&sfc, color)); + + drawYDollarTicks(&sfc, 2, color, 280, 10, 190, 1_000_000, 5_000_000, 5); + try testing.expect(draw.countColor(&sfc, color) > 0); +} + +test "drawXEndpoints draws both endpoint labels" { + const alloc = testing.allocator; + var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 60); + defer sfc.deinit(alloc); + const color = [3]u8{ 0x40, 0x80, 0xC0 }; + drawXEndpoints(&sfc, 2, color, 10, 290, 20, "2024-01-01", "2026-12-31"); + // Both labels contribute pixels; expect a comfortably non-trivial count. + try testing.expect(draw.countColor(&sfc, color) > 20); +} diff --git a/src/charts/chart.zig b/src/charts/chart.zig index 4d038a0..15e3e6c 100644 --- a/src/charts/chart.zig +++ b/src/charts/chart.zig @@ -7,6 +7,7 @@ const z2d = @import("z2d"); const zfin = @import("../root.zig"); const theme = @import("../tui/theme.zig"); const draw = @import("draw.zig"); +const axis = @import("axis.zig"); const Surface = z2d.Surface; @@ -218,6 +219,7 @@ pub fn renderToSurface( height_px: u32, th: theme.Theme, cached: ?*const CachedIndicators, + axis_labels: bool, ) !RenderedChart { if (candles.len < 20) return error.InsufficientData; @@ -280,12 +282,21 @@ pub fn renderToSurface( // Background try draw.fillBackground(&ctx, fwidth, fheight, bg); - // Panel dimensions - const chart_left = margin_left; - const chart_right = fwidth - margin_right; + // Panel dimensions. With axis labels we reserve a left margin + // (price dollar ticks) and a bottom margin (start/end dates), + // scaled to the surface so labels stay legible on large exports. + const label_scale: i32 = axis.labelScale(h); + const label_char_h: f64 = axis.charHeight(label_scale); + const m_left: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_left; + const m_right: f64 = if (axis_labels) label_char_h else margin_right; + const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top; + const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom; + + const chart_left = m_left; + const chart_right = fwidth - m_right; const chart_w = chart_right - chart_left; - const chart_top = margin_top; - const total_h = fheight - margin_top - margin_bottom; + const chart_top = m_top; + const total_h = fheight - m_top - m_bottom; const price_h = total_h * price_frac; const price_top = chart_top; @@ -488,6 +499,18 @@ pub fn renderToSurface( } } + // ── Axis labels (export only) ──────────────────────────────── + if (axis_labels) { + // Dollar ticks against the price panel; start/end dates below. + axis.drawYDollarTicks(&sfc, label_scale, th.text_muted, chart_left, price_top, price_bottom, price_min, price_max, 4); + var fbuf: [12]u8 = undefined; + var lbuf: [12]u8 = undefined; + const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{data[0].date}) catch ""; + const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{data[data.len - 1].date}) catch ""; + const date_y = rsi_bottom + axis.labelGap(label_scale); + axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s); + } + return .{ .surface = sfc, .width = @intCast(width_px), @@ -511,7 +534,7 @@ pub fn renderChart( th: theme.Theme, cached: ?*const CachedIndicators, ) !ChartResult { - var rendered = try renderToSurface(io, alloc, candles, timeframe, width_px, height_px, th, cached); + var rendered = try renderToSurface(io, alloc, candles, timeframe, width_px, height_px, th, cached, false); defer rendered.deinit(alloc); const raw = try rendered.extractRgb(alloc); return .{ @@ -649,7 +672,7 @@ fn buildLinearCandles(arr: []zfin.Candle, start_price: f64) void { test "renderToSurface returns InsufficientData with < 20 candles" { var candles: [10]zfin.Candle = undefined; buildLinearCandles(&candles, 100.0); - const result = renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); + const result = renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false); try std.testing.expectError(error.InsufficientData, result); } @@ -657,7 +680,7 @@ test "renderToSurface produces a populated surface at requested dimensions" { var candles: [30]zfin.Candle = undefined; buildLinearCandles(&candles, 100.0); - var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); + var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false); defer rendered.deinit(test_alloc); try std.testing.expectEqual(@as(u16, 200), rendered.width); @@ -673,7 +696,7 @@ test "renderToSurface price range covers the input close range" { var candles: [30]zfin.Candle = undefined; buildLinearCandles(&candles, 100.0); // closes: 100..129 - var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); + var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false); defer rendered.deinit(test_alloc); // 5% padding is applied inside renderToSurface, so the recorded @@ -703,7 +726,7 @@ test "renderToSurface uses chartClose so split-day cliffs don't widen the price }; } - var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); + var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false); defer rendered.deinit(test_alloc); // With chartClose, max should be near 100 - definitely not 250+. @@ -720,7 +743,7 @@ test "renderToSurface fills background with theme bg" { var th = theme.default_theme; th.bg = .{ 0x12, 0x34, 0x56 }; - var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, th, null); + var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, th, null, false); defer rendered.deinit(test_alloc); // Pixel at (0, 0) is in the top-left margin - outside the chart @@ -740,9 +763,9 @@ test "renderToSurface is deterministic across two calls with same input" { var candles: [30]zfin.Candle = undefined; buildLinearCandles(&candles, 100.0); - var a = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); + var a = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false); defer a.deinit(test_alloc); - var b = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); + var b = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false); defer b.deinit(test_alloc); const buf_a = switch (a.surface) { @@ -768,7 +791,7 @@ test "RenderedChart.extractRgb produces 3 bytes per pixel matching surface buffe var candles: [30]zfin.Candle = undefined; buildLinearCandles(&candles, 100.0); - var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 50, 40, theme.default_theme, null); + var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 50, 40, theme.default_theme, null, false); defer rendered.deinit(test_alloc); const raw = try rendered.extractRgb(test_alloc); diff --git a/src/charts/draw.zig b/src/charts/draw.zig index 2a7e930..037ab64 100644 --- a/src/charts/draw.zig +++ b/src/charts/draw.zig @@ -158,16 +158,20 @@ fn testContext(sfc: *Surface) Context { 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 { +/// Count pixels in the surface that exactly match the given RGB. A +/// shared test helper for the chart modules in this directory (draw, +/// text, axis) that need to assert glyphs/lines actually landed in a +/// known color. `pub` only so the sibling test files can reuse it. +pub fn countColor(sfc: *const Surface, color: [3]u8) usize { const buf = switch (sfc.*) { .image_surface_rgb => |s| s.buf, else => unreachable, }; + var n: usize = 0; for (buf) |px| { - if (px.r == r and px.g == g and px.b == b) return true; + if (px.r == color[0] and px.g == color[1] and px.b == color[2]) n += 1; } - return false; + return n; } test "mapY maps value to pixel coordinate" { @@ -264,7 +268,7 @@ test "drawHLine strokes a line in the requested color" { 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)); + try testing.expect(countColor(&sfc, .{ 0xff, 0x00, 0x00 }) > 0); } test "drawVLine strokes a line in the requested color" { @@ -276,7 +280,7 @@ test "drawVLine strokes a line in the requested color" { 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)); + try testing.expect(countColor(&sfc, .{ 0x00, 0xff, 0x00 }) > 0); } test "drawRect strokes a rectangle outline in the requested color" { @@ -288,7 +292,7 @@ test "drawRect strokes a rectangle outline in the requested color" { 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)); + try testing.expect(countColor(&sfc, .{ 0x00, 0x00, 0xff }) > 0); } test "drawHorizontalGridLines strokes lines in the requested color" { @@ -300,5 +304,5 @@ test "drawHorizontalGridLines strokes lines in the requested color" { 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)); + try testing.expect(countColor(&sfc, .{ 0x33, 0x66, 0x99 }) > 0); } diff --git a/src/charts/line_chart.zig b/src/charts/line_chart.zig index 9100704..8c97548 100644 --- a/src/charts/line_chart.zig +++ b/src/charts/line_chart.zig @@ -30,6 +30,7 @@ const z2d = @import("z2d"); const theme = @import("../tui/theme.zig"); const Date = @import("../Date.zig"); const draw = @import("draw.zig"); +const axis = @import("axis.zig"); const Surface = z2d.Surface; const Context = z2d.Context; @@ -65,6 +66,12 @@ pub const LinePoint = struct { /// Render options. pub const Options = struct { baseline: Baseline = .fit, + /// Draw in-image axis labels (y-axis dollar ticks + x-axis start/end + /// dates) into reserved margins. Off by default: the TUI inline path + /// draws its own terminal-text labels beside the image and wants the + /// bare bitmap. The PNG export turns this on so the standalone file + /// is self-describing. + axis_labels: bool = false, }; /// Line chart render result (raw RGB), produced by `renderLineChart`. @@ -137,12 +144,21 @@ pub fn renderToSurface( // Background try draw.fillBackground(&ctx, fwidth, fheight, bg); - // Chart area - const chart_left = margin_left; - const chart_right = fwidth - margin_right; + // Chart area. When axis labels are enabled we reserve a left margin + // (y-axis dollar ticks) and a bottom margin (start/end dates), scaled + // with the surface so labels stay legible on large exports. + const label_scale: i32 = axis.labelScale(h); + const label_char_h: f64 = axis.charHeight(label_scale); + const m_left: f64 = if (opts.axis_labels) axis.yAxisMargin(label_scale) else margin_left; + const m_right: f64 = if (opts.axis_labels) label_char_h else margin_right; + const m_top: f64 = if (opts.axis_labels) (label_char_h / 2 + 4) else margin_top; + const m_bottom: f64 = if (opts.axis_labels) axis.bottomMargin(label_scale) else margin_bottom; + + const chart_left = m_left; + const chart_right = fwidth - m_right; const chart_w = chart_right - chart_left; - const chart_top = margin_top; - const chart_bottom = fheight - margin_bottom; + const chart_top = m_top; + const chart_bottom = fheight - m_bottom; // ── Value range ────────────────────────────────────────────── var data_min: f64 = points[0].value; @@ -228,6 +244,17 @@ pub fn renderToSurface( try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, border_color, 1.0); } + // ── Axis labels (export only; drawn directly into the buffer) ── + if (opts.axis_labels) { + axis.drawYDollarTicks(&sfc, label_scale, th.text_muted, chart_left, chart_top, chart_bottom, value_min, value_max, 5); + var fbuf: [12]u8 = undefined; + var lbuf: [12]u8 = undefined; + const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{points[0].date}) catch ""; + const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{points[points.len - 1].date}) catch ""; + const date_y = chart_bottom + axis.labelGap(label_scale); + axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s); + } + return .{ .surface = sfc, .width = @intCast(width_px), diff --git a/src/charts/projection_chart.zig b/src/charts/projection_chart.zig index f24fb30..7d66507 100644 --- a/src/charts/projection_chart.zig +++ b/src/charts/projection_chart.zig @@ -19,6 +19,7 @@ const z2d = @import("z2d"); const theme = @import("../tui/theme.zig"); const projections = @import("../analytics/projections.zig"); const draw = @import("draw.zig"); +const axis = @import("axis.zig"); const Surface = z2d.Surface; const Context = z2d.Context; @@ -97,6 +98,7 @@ pub fn renderToSurface( height_px: u32, th: theme.Theme, actuals: ?ActualsOverlay, + axis_labels: bool, ) !RenderedProjection { if (bands.len < 2) return error.InsufficientData; @@ -118,12 +120,19 @@ pub fn renderToSurface( // Background try draw.fillBackground(&ctx, fwidth, fheight, bg); - // Chart area - const chart_left = margin_left; - const chart_right = fwidth - margin_right; + // Chart area. With axis labels we reserve a left margin (dollar + // ticks) and a bottom margin (year endpoints), scaled to the surface. + const label_scale: i32 = axis.labelScale(h); + const label_char_h: f64 = axis.charHeight(label_scale); + const m_left: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_left; + const m_right: f64 = if (axis_labels) label_char_h else margin_right; + const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top; + const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom; + const chart_left = m_left; + const chart_right = fwidth - m_right; const chart_w = chart_right - chart_left; - const chart_top = margin_top; - const chart_bottom = fheight - margin_bottom; + const chart_top = m_top; + const chart_bottom = fheight - m_bottom; // Compute value range from all bands var value_min: f64 = bands[0].p10; @@ -300,6 +309,17 @@ pub fn renderToSurface( try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, border_color, 1.0); } + // ── Axis labels (export only) ──────────────────────────────── + if (axis_labels) { + axis.drawYDollarTicks(&sfc, label_scale, th.text_muted, chart_left, chart_top, chart_bottom, value_min, value_max, 5); + var fbuf: [8]u8 = undefined; + var lbuf: [8]u8 = undefined; + const first_s = std.fmt.bufPrint(&fbuf, "{d}", .{bands[0].year}) catch ""; + const last_s = std.fmt.bufPrint(&lbuf, "{d}", .{bands[bands.len - 1].year}) catch ""; + const yr_y = chart_bottom + axis.labelGap(label_scale); + axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, yr_y, first_s, last_s); + } + return .{ .surface = sfc, .width = @intCast(width_px), @@ -324,7 +344,7 @@ pub fn renderProjectionChart( th: theme.Theme, actuals: ?ActualsOverlay, ) !ProjectionChartResult { - var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals); + var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals, false); defer rendered.deinit(alloc); const raw = try rendered.extractRgb(alloc); return .{ @@ -454,7 +474,7 @@ test "renderToSurface returns a populated RGB surface at requested dimensions" { .{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 }, }; const th = @import("../tui/theme.zig").default_theme; - var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null); + var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null, false); defer rendered.deinit(alloc); try std.testing.expectEqual(@as(u16, 150), rendered.width); @@ -474,7 +494,7 @@ test "renderToSurface fills background with theme bg" { 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); + var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null, false); defer rendered.deinit(alloc); const buf = switch (rendered.surface) { @@ -495,9 +515,9 @@ test "renderToSurface is deterministic across calls with same input" { }; const th = @import("../tui/theme.zig").default_theme; - var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null); + var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false); defer a.deinit(alloc); - var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null); + var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false); defer b.deinit(alloc); const buf_a = switch (a.surface) { @@ -524,7 +544,7 @@ test "RenderedProjection.extractRgb produces 3 bytes per pixel" { .{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 }, }; const th = @import("../tui/theme.zig").default_theme; - var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null); + var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null, false); defer rendered.deinit(alloc); const raw = try rendered.extractRgb(alloc); @@ -549,7 +569,7 @@ test "renderToSurface clamps value_min to zero when bands include negatives" { .{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 }, }; const th = @import("../tui/theme.zig").default_theme; - var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null); + var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false); defer rendered.deinit(alloc); // After 5% padding and the `if (value_min < 0) value_min = 0` diff --git a/src/charts/text.zig b/src/charts/text.zig new file mode 100644 index 0000000..74e8608 --- /dev/null +++ b/src/charts/text.zig @@ -0,0 +1,211 @@ +//! A tiny 5x7 bitmap font for drawing axis labels directly into a z2d +//! `image_surface_rgb` pixel buffer. +//! +//! Why a hand-rolled bitmap font instead of `z2d.text` + a TTF? +//! 1. The chart renderers in this directory deliberately draw with +//! anti-aliasing OFF and the `.src` operator, pre-blending colors +//! against the background to sidestep z2d's `src_over` compositor +//! overflow on semi-transparent fills. Glyph outline rasterization +//! needs alpha blending for its edges and would hit that same bug. +//! Solid 1-bit pixels avoid it entirely. +//! 2. It keeps a ~hundreds-of-KB TTF (and its license) out of the repo. +//! +//! The glyph set is intentionally minimal - just what axis labels need: +//! digits, `$`, `.`, `,`, `-`, and the `T`/`B`/`M` magnitude suffixes +//! emitted by `format.fmtLargeNum`, plus space. Unknown chars render blank. +//! +//! Coordinates are in surface pixels; `scale` multiplies the 5x7 cell +//! (so `scale = 3` renders 15x21 glyphs). Drawing is clipped to the +//! surface bounds. Callers own pixel layout (margins, alignment); this +//! module only stamps glyphs. + +const std = @import("std"); +const z2d = @import("z2d"); +const draw = @import("draw.zig"); + +const Surface = z2d.Surface; +const RGB = z2d.pixel.RGB; + +/// Glyph cell dimensions, in font pixels (pre-scale). +pub const glyph_w: i32 = 5; +pub const glyph_h: i32 = 7; +/// Horizontal advance per glyph including the 1px inter-glyph gap. +pub const advance: i32 = glyph_w + 1; + +/// Each glyph is 7 rows; the low 5 bits of each row are the pixels, +/// bit 4 (0b10000) = leftmost column. Row 0 is the top. +const Glyph = [7]u8; + +const blank: Glyph = .{ 0, 0, 0, 0, 0, 0, 0 }; + +const digits = [10]Glyph{ + .{ 0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E }, // 0 + .{ 0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E }, // 1 + .{ 0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F }, // 2 + .{ 0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E }, // 3 + .{ 0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02 }, // 4 + .{ 0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E }, // 5 + .{ 0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E }, // 6 + .{ 0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08 }, // 7 + .{ 0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E }, // 8 + .{ 0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C }, // 9 +}; + +const glyph_dollar: Glyph = .{ 0x04, 0x0E, 0x14, 0x0E, 0x05, 0x0E, 0x04 }; +const glyph_period: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06 }; +const glyph_comma: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x08 }; +const glyph_minus: Glyph = .{ 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00 }; +const glyph_T: Glyph = .{ 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 }; +const glyph_B: Glyph = .{ 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E }; +const glyph_M: Glyph = .{ 0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11 }; + +/// Look up the bitmap for a character. Unknown characters (including +/// space) render blank. +fn glyphFor(ch: u8) Glyph { + return switch (ch) { + '0'...'9' => digits[ch - '0'], + '$' => glyph_dollar, + '.' => glyph_period, + ',' => glyph_comma, + '-' => glyph_minus, + 'T' => glyph_T, + 'B' => glyph_B, + 'M' => glyph_M, + else => blank, + }; +} + +/// Width in surface pixels that `drawText` will occupy for `text` at +/// `scale` (excludes the trailing inter-glyph gap). Zero for empty text. +/// Useful for right-aligning a label against a known x. +pub fn measureWidth(text: []const u8, scale: i32) i32 { + if (text.len == 0) return 0; + const n: i32 = @intCast(text.len); + // n full glyphs + (n-1) gaps = n*advance - 1, times scale. + return (n * advance - 1) * scale; +} + +/// Stamp `text` onto the surface's RGB buffer with its top-left at +/// `(x, y)`, each font pixel drawn as a `scale` x `scale` solid block in +/// `color`. No-op for non-RGB surfaces. Pixels outside the surface are +/// clipped. +pub fn drawText(sfc: *Surface, x: i32, y: i32, scale: i32, color: [3]u8, text: []const u8) void { + if (scale <= 0) return; + const img = switch (sfc.*) { + .image_surface_rgb => |s| s, + else => return, + }; + const w = img.width; + const h = img.height; + const px = RGB{ .r = color[0], .g = color[1], .b = color[2] }; + + var cursor_x = x; + for (text) |ch| { + const glyph = glyphFor(ch); + for (0..@intCast(glyph_h)) |gr| { + const bits = glyph[gr]; + for (0..@intCast(glyph_w)) |gc| { + const mask: u8 = @as(u8, 1) << @intCast(glyph_w - 1 - @as(i32, @intCast(gc))); + if (bits & mask == 0) continue; + fillBlock( + img.buf, + w, + h, + cursor_x + @as(i32, @intCast(gc)) * scale, + y + @as(i32, @intCast(gr)) * scale, + scale, + px, + ); + } + } + cursor_x += advance * scale; + } +} + +/// Fill a `size` x `size` block of pixels at `(bx, by)`, clipped to the +/// `[0,w) x [0,h)` surface bounds. +fn fillBlock(buf: []RGB, w: i32, h: i32, bx: i32, by: i32, size: i32, px: RGB) void { + var dy: i32 = 0; + while (dy < size) : (dy += 1) { + const yy = by + dy; + if (yy < 0 or yy >= h) continue; + var dx: i32 = 0; + while (dx < size) : (dx += 1) { + const xx = bx + dx; + if (xx < 0 or xx >= w) continue; + buf[@intCast(yy * w + xx)] = px; + } + } +} + +// ── Tests ───────────────────────────────────────────────────────────── + +const testing = std.testing; + +test "measureWidth: empty is zero, scales with length" { + try testing.expectEqual(@as(i32, 0), measureWidth("", 3)); + // One glyph = glyph_w * scale (no trailing gap). + try testing.expectEqual(@as(i32, glyph_w * 2), measureWidth("1", 2)); + // Two glyphs = (2*advance - 1) * scale = (2*6 - 1)*2 = 22. + try testing.expectEqual(@as(i32, 22), measureWidth("12", 2)); +} + +test "drawText stamps glyph pixels in the requested color" { + const alloc = testing.allocator; + var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 16); + defer sfc.deinit(alloc); + + // Surface starts zeroed (black). Draw white text. + const white = [3]u8{ 0xFF, 0xFF, 0xFF }; + try testing.expectEqual(@as(usize, 0), draw.countColor(&sfc, white)); + + drawText(&sfc, 1, 1, 1, white, "1"); + // '1' has 10 set pixels in the 5x7 bitmap; at scale 1 that's 10 white px. + try testing.expectEqual(@as(usize, 10), draw.countColor(&sfc, white)); +} + +test "drawText scale multiplies the stamped pixel count" { + const alloc = testing.allocator; + var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 32); + defer sfc.deinit(alloc); + + const c = [3]u8{ 0x10, 0x20, 0x30 }; + drawText(&sfc, 0, 0, 2, c, "1"); + // 10 set font-pixels, each a 2x2 block -> 10 * 4 = 40 colored px. + try testing.expectEqual(@as(usize, 40), draw.countColor(&sfc, c)); +} + +test "drawText clips out-of-bounds without writing past the buffer" { + const alloc = testing.allocator; + var sfc = try Surface.init(.image_surface_rgb, alloc, 8, 8); + defer sfc.deinit(alloc); + + const c = [3]u8{ 0xAA, 0xBB, 0xCC }; + // Draw partly off the right/bottom edge and fully off-screen; must + // not crash or panic (bounds-checked writes). + drawText(&sfc, 6, 6, 3, c, "8"); + drawText(&sfc, -50, -50, 4, c, "8"); + drawText(&sfc, 100, 100, 4, c, "8"); + // Some pixels of the first (partly-visible) glyph may have landed. + try testing.expect(draw.countColor(&sfc, c) <= 8 * 9); +} + +test "drawText ignores unknown glyphs (renders blank)" { + const alloc = testing.allocator; + var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 16); + defer sfc.deinit(alloc); + const white = [3]u8{ 0xFF, 0xFF, 0xFF }; + // '?' and space are not in the set -> nothing drawn. + drawText(&sfc, 1, 1, 2, white, " ?"); + try testing.expectEqual(@as(usize, 0), draw.countColor(&sfc, white)); +} + +test "drawText renders the comma glyph (so thousands separators show)" { + const alloc = testing.allocator; + var sfc = try Surface.init(.image_surface_rgb, alloc, 16, 16); + defer sfc.deinit(alloc); + const white = [3]u8{ 0xFF, 0xFF, 0xFF }; + drawText(&sfc, 1, 1, 1, white, ","); + // The comma bitmap (rows 0x06,0x06,0x08) has 5 set pixels. + try testing.expectEqual(@as(usize, 5), draw.countColor(&sfc, white)); +} diff --git a/src/commands/history.zig b/src/commands/history.zig index 6e3faf7..f113a23 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -16,6 +16,10 @@ //! Defaults to auto: daily ≤90d, weekly ≤730d, //! else monthly. //! --limit cap the "Recent snapshots" table to N rows +//! --export-chart render the focused-metric timeline as a +//! PNG to and exit (honors +//! --since / --until / --metric / --baseline) +//! --baseline exported-chart y-axis: fit (default) | zero //! --rebuild-rollup (re)write history/rollup.srf and exit //! //! Portfolio layout, top-to-bottom: @@ -38,6 +42,8 @@ const timeline = @import("../analytics/timeline.zig"); const history = @import("../history.zig"); const snapshot_model = @import("../models/snapshot.zig"); const view = @import("../views/history.zig"); +const chart_export = @import("../chart_export.zig"); +const line_chart = @import("../charts/line_chart.zig"); const fmt = cli.fmt; const Date = @import("../Date.zig"); @@ -78,12 +84,14 @@ pub const meta: framework.Meta = .{ \\ --resolution daily | weekly | monthly | auto \\ (auto: daily ≤90d, weekly ≤730d, else monthly) \\ --limit cap recent-snapshots table to N rows (default 40) + \\ --export-chart write the focused-metric timeline as a PNG and exit + \\ --baseline exported-chart y-axis: fit (default) or zero \\ --rebuild-rollup regenerate history/rollup.srf and exit \\ \\DATE accepts YYYY-MM-DD or relative shortcuts (1W/1M/1Q/1Y). \\ , - .user_errors = error{ UnexpectedArg, MissingFlagValue, InvalidFlagValue, UnknownMetric, UnknownResolution }, + .user_errors = error{ UnexpectedArg, MissingFlagValue, InvalidFlagValue, UnknownMetric, UnknownResolution, UnknownBaseline }, }; pub const Error = error{ @@ -92,6 +100,7 @@ pub const Error = error{ MissingFlagValue, UnknownMetric, UnknownResolution, + UnknownBaseline, }; /// Parsed portfolio-mode options. Separated from `run` so the parser @@ -114,6 +123,15 @@ pub const PortfolioOpts = struct { /// Max rows shown in the recent-snapshots table. Null means default (40). limit: ?usize = null, rebuild_rollup: bool = false, + /// When set, render the focused-metric timeline as a PNG to this + /// path and exit, instead of printing the normal timeline output. + /// Honors `--since` / `--until` / `--metric`. + export_chart: ?[]const u8 = null, + /// Y-axis baseline for the exported chart: `.fit` fits the data + /// range; `.zero` anchors the floor at zero (clamped to the data + /// minimum if the series itself dips negative). Only consulted when + /// `--export-chart` is given. + baseline: line_chart.Baseline = .fit, }; /// Parse the arg list for portfolio-mode flags. Pure function - no IO. @@ -155,6 +173,14 @@ pub fn parsePortfolioOpts(as_of: zfin.Date, args: []const []const u8) Error!Port opts.limit = std.fmt.parseInt(usize, args[i], 10) catch return error.InvalidFlagValue; } else if (std.mem.eql(u8, a, "--rebuild-rollup")) { opts.rebuild_rollup = true; + } else if (std.mem.eql(u8, a, "--export-chart")) { + i += 1; + if (i >= args.len) return error.MissingFlagValue; + opts.export_chart = args[i]; + } else if (std.mem.eql(u8, a, "--baseline")) { + i += 1; + if (i >= args.len) return error.MissingFlagValue; + opts.baseline = std.meta.stringToEnum(line_chart.Baseline, args[i]) orelse return error.UnknownBaseline; } else { return error.UnexpectedArg; } @@ -177,6 +203,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr error.InvalidFlagValue => cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"), error.UnknownMetric => cli.stderrPrint(ctx.io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"), error.UnknownResolution => cli.stderrPrint(ctx.io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"), + error.UnknownBaseline => cli.stderrPrint(ctx.io, "Error: unknown --baseline. Valid: fit, zero.\n"), } return err; }; @@ -289,6 +316,26 @@ fn runPortfolio( return; } + // --export-chart short-circuits: render the focused-metric timeline + // as a PNG (honoring --since / --until / --metric / --baseline) and + // exit without printing the normal timeline output. + if (opts.export_chart) |path| { + exportMetricChart(io, allocator, filtered, opts.metric, opts.baseline, path) catch |err| switch (err) { + error.InsufficientData => { + cli.stderrPrint(io, "Error: need at least 2 snapshots in the selected range to render a chart.\n"); + return; + }, + else => { + cli.stderrPrint(io, "Error exporting chart: "); + cli.stderrPrint(io, @errorName(err)); + cli.stderrPrint(io, "\n"); + return; + }, + }; + try out.print("Chart exported to {s}\n", .{path}); + return; + } + // Resolve the effective resolution: // - explicit `--resolution daily/weekly/monthly/cascading` -> // use as-is. @@ -459,6 +506,38 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet) } } +/// Convert timeline `MetricPoint`s into the chart module's `LinePoint` +/// shape. Same fields, distinct type: the chart module deliberately +/// does not depend on `analytics/timeline.zig`, so the CLI does the +/// conversion at the boundary (mirrors how `projections` converts its +/// overlay points). Caller owns the returned slice. +fn metricLinePoints(allocator: std.mem.Allocator, series: []const timeline.MetricPoint) ![]line_chart.LinePoint { + const out = try allocator.alloc(line_chart.LinePoint, series.len); + for (series, 0..) |mp, i| out[i] = .{ .date = mp.date, .value = mp.value }; + return out; +} + +/// Render the focused-metric portfolio timeline to a PNG at `path`. +/// Extracts the metric series via `timeline.extractChartSeries` (the +/// single home for the "skip imported-only points for derived metrics" +/// rule), converts to `LinePoint`s, and hands off to +/// `chart_export.exportTimelineChart`. Propagates `error.InsufficientData` +/// when fewer than 2 points remain after extraction. +fn exportMetricChart( + io: std.Io, + allocator: std.mem.Allocator, + points: []const timeline.TimelinePoint, + metric: timeline.Metric, + baseline: line_chart.Baseline, + path: []const u8, +) !void { + const series = try timeline.extractChartSeries(allocator, points, metric); + defer allocator.free(series); + const lps = try metricLinePoints(allocator, series); + defer allocator.free(lps); + try chart_export.exportTimelineChart(io, allocator, lps, baseline, path); +} + fn renderBrailleChart( allocator: std.mem.Allocator, out: *std.Io.Writer, @@ -468,33 +547,30 @@ fn renderBrailleChart( ) !void { if (points.len < 2) return; - // Synthesize candles from the focused metric's value. For - // illiquid / net_worth, skip imported-only points so the - // line is visually absent in the imported-only range rather - // than hugging zero. + // Extract the focused-metric series via the shared + // `extractChartSeries` rule (skips imported-only points for the + // derived metrics so the line is visually absent in the + // imported-only range rather than hugging zero), then synthesize + // flat candles for the braille renderer. + const series = try timeline.extractChartSeries(allocator, points, metric); + defer allocator.free(series); + if (series.len < 2) return; + var candles_list: std.ArrayList(zfin.Candle) = .empty; defer candles_list.deinit(allocator); - try candles_list.ensureTotalCapacity(allocator, points.len); - const skip_imported = (metric == .illiquid) or (metric == .net_worth); - for (points) |p| { - if (skip_imported and p.source == .imported) continue; - const v = switch (metric) { - .net_worth => p.net_worth, - .liquid => p.liquid, - .illiquid => p.illiquid, - }; - try candles_list.append(allocator, .{ - .date = p.as_of_date, - .open = v, - .high = v, - .low = v, - .close = v, - .adj_close = v, + try candles_list.ensureTotalCapacity(allocator, series.len); + for (series) |mp| { + candles_list.appendAssumeCapacity(.{ + .date = mp.date, + .open = mp.value, + .high = mp.value, + .low = mp.value, + .close = mp.value, + .adj_close = mp.value, .volume = 0, }); } const candles = candles_list.items; - if (candles.len < 2) return; var chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return; defer chart.deinit(allocator); @@ -791,6 +867,29 @@ test "parsePortfolioOpts: unknown flag / value errors" { try testing.expectError(error.InvalidFlagValue, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{ "--limit", "not-a-number" })); } +test "parsePortfolioOpts: --export-chart captures the path, baseline defaults to fit" { + const args = [_][]const u8{ "--export-chart", "timeline.png" }; + const o = try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &args); + try testing.expectEqualStrings("timeline.png", o.export_chart.?); + try testing.expectEqual(line_chart.Baseline.fit, o.baseline); +} + +test "parsePortfolioOpts: --baseline parses fit and zero" { + const af = [_][]const u8{ "--baseline", "fit" }; + try testing.expectEqual(line_chart.Baseline.fit, (try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &af)).baseline); + + const az = [_][]const u8{ "--baseline", "zero" }; + try testing.expectEqual(line_chart.Baseline.zero, (try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &az)).baseline); +} + +test "parsePortfolioOpts: chart flag errors" { + // Unknown baseline value. + try testing.expectError(error.UnknownBaseline, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{ "--baseline", "bogus" })); + // Missing flag values. + try testing.expectError(error.MissingFlagValue, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{"--export-chart"})); + try testing.expectError(error.MissingFlagValue, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{"--baseline"})); +} + // ── renderPortfolio (end-to-end) ───────────────────────────── fn makeTimelinePoint(y: i16, m: u8, d: u8, liq: f64, ill: f64, nw: f64) timeline.TimelinePoint { @@ -952,6 +1051,62 @@ test "displaySymbol empty candles" { try testing.expect(std.mem.indexOf(u8, out, "0 trading days") != null); } +// ── chart export ───────────────────────────────────────────── + +test "metricLinePoints converts MetricPoints preserving order and values" { + const series = [_]timeline.MetricPoint{ + .{ .date = Date.fromYmd(2026, 1, 1), .value = 100 }, + .{ .date = Date.fromYmd(2026, 2, 1), .value = 250 }, + }; + const lps = try metricLinePoints(testing.allocator, &series); + defer testing.allocator.free(lps); + try testing.expectEqual(@as(usize, 2), lps.len); + try testing.expect(lps[0].date.eql(Date.fromYmd(2026, 1, 1))); + try testing.expectEqual(@as(f64, 100), lps[0].value); + try testing.expect(lps[1].date.eql(Date.fromYmd(2026, 2, 1))); + try testing.expectEqual(@as(f64, 250), lps[1].value); +} + +test "exportMetricChart writes a PNG for a multi-point timeline" { + const io = std.testing.io; + const alloc = testing.allocator; + const pts = [_]timeline.TimelinePoint{ + makeTimelinePoint(2026, 1, 1, 1_000_000, 200_000, 1_200_000), + makeTimelinePoint(2026, 2, 1, 1_050_000, 210_000, 1_260_000), + makeTimelinePoint(2026, 3, 1, 1_030_000, 205_000, 1_235_000), + }; + + var tmp = 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 path = try std.fs.path.join(alloc, &.{ path_buf[0..dir_len], "history_timeline.png" }); + defer alloc.free(path); + + try exportMetricChart(io, alloc, &pts, .liquid, .fit, path); + + var file = try tmp.dir.openFile(io, "history_timeline.png", .{}); + defer file.close(io); + const size = (try file.stat(io)).size; + try testing.expect(size > 1024); + + var magic: [8]u8 = undefined; + var reader = file.reader(io, &.{}); + _ = try reader.interface.readSliceShort(&magic); + try testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic); +} + +test "exportMetricChart returns InsufficientData with fewer than 2 points" { + const io = std.testing.io; + const alloc = testing.allocator; + // Single point -> extractChartSeries yields 1 point -> renderToSurface + // rejects it before any file is opened, so `path` is never written. + const pts = [_]timeline.TimelinePoint{ + makeTimelinePoint(2026, 1, 1, 1_000_000, 200_000, 1_200_000), + }; + try testing.expectError(error.InsufficientData, exportMetricChart(io, alloc, &pts, .liquid, .fit, "unused.png")); +} + // ── rebuildRollup ──────────────────────────────────────────── fn makeFixtureSnapshot(