chart refactor + history gains --export-chart
This commit is contained in:
parent
e6ec5fdac1
commit
6170dc1ac1
8 changed files with 661 additions and 62 deletions
|
|
@ -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
157
src/charts/axis.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
211
src/charts/text.zig
Normal 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));
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue