chart refactor + history gains --export-chart

This commit is contained in:
Emil Lerch 2026-06-25 15:22:10 -07:00
parent e6ec5fdac1
commit 6170dc1ac1
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 661 additions and 62 deletions

View file

@ -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,

157
src/charts/axis.zig Normal file
View file

@ -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);
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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),

View file

@ -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`

211
src/charts/text.zig Normal file
View file

@ -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));
}

View file

@ -16,6 +16,10 @@
//! Defaults to auto: daily 90d, weekly 730d,
//! else monthly.
//! --limit <N> cap the "Recent snapshots" table to N rows
//! --export-chart <path> render the focused-metric timeline as a
//! PNG to <path> and exit (honors
//! --since / --until / --metric / --baseline)
//! --baseline <name> 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 <name> daily | weekly | monthly | auto
\\ (auto: daily ≤90d, weekly ≤730d, else monthly)
\\ --limit <N> cap recent-snapshots table to N rows (default 40)
\\ --export-chart <path> write the focused-metric timeline as a PNG and exit
\\ --baseline <name> 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(