export chart as png

This commit is contained in:
Emil Lerch 2026-05-19 14:18:53 -07:00
parent 4d1587fb37
commit 3e18610768
7 changed files with 878 additions and 77 deletions

View file

@ -235,6 +235,17 @@ Per-symbol price change (5 held throughout)
``` ```
### Chart export (`--export-chart <PATH>`)
The `quote` and `projections` commands support `--export-chart <PATH>` to render their charts as PNG files (1920x1080) instead of emitting text. Useful for write-ups, sharing, or capturing a back-dated projection without screenshot-and-crop.
```
zfin quote AAPL --export-chart aapl.png
zfin projections --as-of 1Y --overlay-actuals --export-chart proj.png
```
The exported image uses the TUI's default theme. When `--export-chart` is set, no other text output is emitted — the command exits after writing the file. Only the default `projections` mode is supported; `--convergence`, `--return-backtest`, and `--vs` reject the flag (their charts still need PNG plumbing). The `history` command's portfolio-value chart is also not yet exportable — it uses a single-series braille format that doesn't share the z2d pipeline used by `quote` and `projections`.
### Interactive TUI flags ### Interactive TUI flags
``` ```

68
TODO.md
View file

@ -102,37 +102,47 @@ ranking; unlabeled items are "someday, if the mood strikes."
faithfulness one notch. Pick whichever has the highest faithfulness one notch. Pick whichever has the highest
payoff vs. complexity when this gets revisited. payoff vs. complexity when this gets revisited.
## Export chart as PNG (`--export-chart <path>`) — priority MEDIUM ## `--export-chart` follow-ups — priority LOW
z2d already supports PNG export natively. Today the chart-bearing V1 of `--export-chart <PATH>` shipped for `quote` and `projections`
commands (`quote`, `history`, `projections`, plus the equivalent TUI (default bands mode only). Several adjacent surfaces still don't
tabs) render to braille (CLI) or Kitty graphics (TUI). Adding a have PNG export and were deferred:
`--export-chart <path>` flag would land just the chart (not the
surrounding text output) as a PNG file at the given path, at full
fidelity, regardless of which surface invoked it.
Driver: when reviewing a back-dated projection or a notable price - **`history --export-chart`.** The `history` command renders a
move, capturing the chart as an image (e.g. for a write-up, an email single-series braille chart of portfolio value over time
to the household, or a wiki page) is currently a screenshot-and-crop (synthesized into `Candle` records and fed to
chore. PNG export makes it a one-shot CLI invocation. `format.computeBrailleChart`). It doesn't share the z2d
pipeline that `quote` (`tui/chart.zig`) and `projections`
Sketch: (`tui/projection_chart.zig`) use. To export, options:
- `zfin quote AAPL --export-chart aapl.png` → just the price+ - **A.** Pipe the synthesized candles through
Bollinger chart as a PNG, no other output. `tui/chart.zig`'s `renderChart` — but that draws Bollinger
- `zfin projections --as-of 1Y --overlay-actuals --export-chart projection.png` Bands and an RSI panel, both meaningless on a portfolio-
→ the projection-bands chart plus overlay, no other output. value series.
- The chart code already produces RGB pixel buffers via z2d; replace - **B.** Add a minimal "single-series line chart" z2d
the `transmitPreEncodedImage` call (TUI) or the braille text path renderer (a slimmed-down `projection_chart.zig` without
(CLI) with a `Surface.write_png` call when the flag is present. bands). ~150 lines. Same renderToSurface shape so PNG
export is trivial after.
Plumbing: a thin "chart-only render" entry point in each chart - **C.** Skip it permanently; the braille chart is fine for
module (`projection_chart.zig`, `chart.zig` for symbols), called what `history` is. Document as "not exportable".
from the relevant command's `run()` when `--export-chart` is set. B is the right answer if PNG export of the history chart is
Exits before the rest of the text output renders. ever requested.
- **`projections --convergence` / `--return-backtest`.** Both
Out of scope for V1: file-format alternatives (SVG, PDF), themed render forecast-evaluation charts via `tui/forecast_chart.zig`.
color overrides for export (always uses the active terminal theme), Not refactored to expose a `renderToSurface` seam yet —
non-chart command output as PNG. parser rejects `--export-chart` in those modes today. Low
effort to add (mirror the `tui/chart.zig` pattern).
- **`projections --vs <DATE>`.** No chart at all in this mode
(text-only delta table); `--export-chart` rejected at parse
time. Could grow a side-by-side bands comparison chart, but
that's a feature of its own — not just an export plumbing job.
- **Theme overrides at export time.** Today the export always
uses `theme.default_theme`. A `--theme <PATH>` flag at export
time would let users render with their configured theme or a
presentation-friendly one. Out of scope for V1; gate when
someone asks for it.
- **File format alternatives.** SVG / PDF / WebP — `z2d` only
exports PNG natively today; would need an external dependency
or a pixel-buffer-to-format conversion.
## `zfin doctor` health-check command — priority LOW ## `zfin doctor` health-check command — priority LOW

236
src/chart_export.zig Normal file
View file

@ -0,0 +1,236 @@
//! Chart-to-PNG export.
//!
//! The CLI's `--export-chart <path>` flag funnels through this
//! module. Each chart-bearing command (`quote`, `projections`)
//! has a corresponding `export*` function here that:
//!
//! 1. Calls the relevant `renderToSurface` (in `tui/chart.zig` or
//! `tui/projection_chart.zig`) to draw the chart into a z2d
//! `Surface`.
//! 2. Calls `z2d.png_exporter.writeToPNGFile` to land the surface
//! as a PNG file at the user-supplied path.
//! 3. Frees the surface.
//!
//! Default export resolution is 1920x1080 matches the TUI's
//! `chart_config.max_width`/`max_height` defaults so the exported
//! image has the same fidelity the user sees in the terminal.
//! The TUI's adaptive chart sizing isn't used here because the
//! export target is a file, not a cell grid.
//!
//! The module deliberately reuses the TUI's `default_theme` for
//! consistent visual identity across the live TUI surface and
//! exported images. A `--theme` override at export time is out of
//! scope for V1 (see TODO follow-up).
const std = @import("std");
const z2d = @import("z2d");
const zfin = @import("root.zig");
const chart = @import("tui/chart.zig");
const projection_chart = @import("tui/projection_chart.zig");
const projections = @import("analytics/projections.zig");
const theme = @import("tui/theme.zig");
/// Default PNG export resolution. Matches `tui/chart.zig`'s
/// `ChartConfig.max_width/max_height` defaults so an exported
/// image carries the same fidelity as a maximally-sized TUI
/// chart.
pub const default_width: u32 = 1920;
pub const default_height: u32 = 1080;
/// Export a price+Bollinger+RSI chart for a single symbol.
/// Wraps `chart.renderToSurface` + `writeToPNGFile`.
pub fn exportSymbolChart(
io: std.Io,
alloc: std.mem.Allocator,
candles: []const zfin.Candle,
timeframe: chart.Timeframe,
path: []const u8,
) !void {
var rendered = chart.renderToSurface(
io,
alloc,
candles,
timeframe,
default_width,
default_height,
theme.default_theme,
null,
) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
else => return err,
};
defer rendered.deinit(alloc);
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
}
/// Export a projection percentile-band chart with optional actuals
/// overlay. Wraps `projection_chart.renderToSurface` +
/// `writeToPNGFile`.
pub fn exportProjectionChart(
io: std.Io,
alloc: std.mem.Allocator,
bands: []const projections.YearPercentiles,
actuals: ?projection_chart.ActualsOverlay,
path: []const u8,
) !void {
var rendered = projection_chart.renderToSurface(
io,
alloc,
bands,
default_width,
default_height,
theme.default_theme,
actuals,
) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
else => return err,
};
defer rendered.deinit(alloc);
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
}
// Tests
test "exportSymbolChart writes a non-empty PNG file" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
// Minimum candle count (renderToSurface requires >= 20)
var candles: [25]zfin.Candle = undefined;
for (0..25) |i| {
const price: f64 = 100.0 + @as(f64, @floatFromInt(i));
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price + 1,
.low = price - 1,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol.png" });
defer alloc.free(path);
try exportSymbolChart(io, alloc, &candles, .@"6M", path);
// Verify the file exists, starts with the PNG magic, and is
// big enough to plausibly contain a chart (not just headers).
var file = try tmp.dir.openFile(io, "test_export_symbol.png", .{});
defer file.close(io);
const size = (try file.stat(io)).size;
try std.testing.expect(size > 1024); // arbitrary lower bound
var magic: [8]u8 = undefined;
var reader = file.reader(io, &.{});
_ = try reader.interface.readSliceShort(&magic);
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
}
test "exportSymbolChart returns InsufficientData on too-few candles" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
var candles: [10]zfin.Candle = undefined;
for (0..10) |i| {
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = 100,
.high = 100,
.low = 100,
.close = 100,
.adj_close = 100,
.volume = 0,
};
}
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol_insufficient.png" });
defer alloc.free(path);
try std.testing.expectError(
error.InsufficientData,
exportSymbolChart(io, alloc, &candles, .@"6M", path),
);
}
test "exportProjectionChart writes a non-empty PNG file" {
const alloc = std.testing.allocator;
const io = std.testing.io;
// Synthetic 11-year horizon: bands grow linearly, all
// percentiles spread around p50.
var bands: [11]projections.YearPercentiles = undefined;
for (0..11) |i| {
const base: f64 = 100_000.0 * (1.0 + 0.07 * @as(f64, @floatFromInt(i)));
bands[i] = .{
.year = @intCast(i),
.p10 = base * 0.6,
.p25 = base * 0.8,
.p50 = base,
.p75 = base * 1.2,
.p90 = base * 1.5,
};
}
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_projection.png" });
defer alloc.free(path);
try exportProjectionChart(io, alloc, &bands, null, path);
var file = try tmp.dir.openFile(io, "test_export_projection.png", .{});
defer file.close(io);
const size = (try file.stat(io)).size;
try std.testing.expect(size > 1024);
var magic: [8]u8 = undefined;
var reader = file.reader(io, &.{});
_ = try reader.interface.readSliceShort(&magic);
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
}
test "exportProjectionChart returns InsufficientData with single band" {
const alloc = std.testing.allocator;
const io = std.testing.io;
var bands: [1]projections.YearPercentiles = .{.{
.year = 0,
.p10 = 100,
.p25 = 110,
.p50 = 120,
.p75 = 130,
.p90 = 140,
}};
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_projection_insufficient.png" });
defer alloc.free(path);
try std.testing.expectError(
error.InsufficientData,
exportProjectionChart(io, alloc, &bands, null, path),
);
}

View file

@ -26,6 +26,7 @@ const imported = @import("../data/imported_values.zig");
const forecast = @import("../analytics/forecast_evaluation.zig"); const forecast = @import("../analytics/forecast_evaluation.zig");
const milestones = @import("../analytics/milestones.zig"); const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig"); const shiller = @import("../data/shiller.zig");
const chart_export = @import("../chart_export.zig");
/// Hardcoded benchmark symbols (configurable in a future version). /// Hardcoded benchmark symbols (configurable in a future version).
const stock_benchmark = "SPY"; const stock_benchmark = "SPY";
@ -55,6 +56,11 @@ pub const BandsArgs = struct {
/// `null` means live (today). Non-null = historical snapshot. /// `null` means live (today). Non-null = historical snapshot.
as_of: ?Date = null, as_of: ?Date = null,
overlay_actuals: bool = false, overlay_actuals: bool = false,
/// When set, render the percentile-band chart (with optional
/// overlay) as a PNG to this path and exit. No text output.
/// Only supported in the default bands mode; --convergence,
/// --return-backtest, and --vs reject the flag at parse time.
export_chart: ?[]const u8 = null,
}; };
pub const CompareArgs = struct { pub const CompareArgs = struct {
@ -103,6 +109,11 @@ pub const meta: framework.Meta = .{
\\ --return-backtest (see above) \\ --return-backtest (see above)
\\ --real With --return-backtest, render in \\ --real With --return-backtest, render in
\\ CPI-adjusted dollars. \\ CPI-adjusted dollars.
\\ --export-chart <PATH> Render the percentile-band chart
\\ (with optional overlay if
\\ --overlay-actuals is set) as a PNG
\\ to PATH (1920x1080) and exit. Only
\\ valid in the default bands mode.
\\ \\
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y). \\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
\\ \\
@ -122,6 +133,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
var convergence = false; var convergence = false;
var return_backtest = false; var return_backtest = false;
var real_mode = false; var real_mode = false;
var export_chart: ?[]const u8 = null;
var i: usize = 0; var i: usize = 0;
while (i < cmd_args.len) : (i += 1) { while (i < cmd_args.len) : (i += 1) {
@ -136,6 +148,13 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
return_backtest = true; return_backtest = true;
} else if (std.mem.eql(u8, a, "--real")) { } else if (std.mem.eql(u8, a, "--real")) {
real_mode = true; real_mode = true;
} else if (std.mem.eql(u8, a, "--export-chart")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(io, "Error: --export-chart requires a path argument.\n");
return error.MissingFlagValue;
}
export_chart = cmd_args[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) { } else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) {
if (i + 1 >= cmd_args.len) { if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(io, "Error: "); try cli.stderrPrint(io, "Error: ");
@ -193,6 +212,14 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
try cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n"); try cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
return error.MutuallyExclusive; return error.MutuallyExclusive;
} }
// Chart export only meaningful in default bands mode. The
// forecast-evaluation views (convergence, return-backtest)
// render via `forecast_chart.zig` which doesn't have a PNG
// export path yet; --vs is text-only with no chart at all.
if (export_chart != null and (convergence or return_backtest or vs_date != null)) {
try cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n");
return error.MutuallyExclusive;
}
if (convergence) return ParsedArgs{ .convergence = {} }; if (convergence) return ParsedArgs{ .convergence = {} };
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode } }; if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode } };
@ -207,6 +234,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
.events_enabled = events_enabled, .events_enabled = events_enabled,
.as_of = as_of, .as_of = as_of,
.overlay_actuals = overlay_actuals, .overlay_actuals = overlay_actuals,
.export_chart = export_chart,
} }; } };
} }
@ -256,6 +284,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.today = today, .today = today,
.overlay_actuals = args.overlay_actuals, .overlay_actuals = args.overlay_actuals,
.refresh = ctx.globals.refresh_policy, .refresh = ctx.globals.refresh_policy,
.export_chart = args.export_chart,
}, },
color, color,
out, out,
@ -319,6 +348,10 @@ pub const BandsOptions = struct {
/// Refresh policy threaded through the live "now" price /// Refresh policy threaded through the live "now" price
/// load. Has no effect when `from_snapshot = true`. /// load. Has no effect when `from_snapshot = true`.
refresh: framework.RefreshPolicy = .auto, refresh: framework.RefreshPolicy = .auto,
/// When set, render the percentile-band chart (with optional
/// overlay) to a PNG at this path and exit before any text
/// output renders. See `chart_export.exportProjectionChart`.
export_chart: ?[]const u8 = null,
}; };
pub fn runBands( pub fn runBands(
@ -490,6 +523,55 @@ pub fn runBands(
} }
} }
// PNG export short-circuit
//
// When --export-chart is set, render the percentile-band chart
// (with overlay if loaded) to the requested PNG path and exit
// before any text output. Uses the longest configured horizon
// matching what the TUI shows by default.
if (opts.export_chart) |export_path| {
const horizons_ec = ctx.config.getHorizons();
if (horizons_ec.len == 0) {
try cli.stderrPrint(io, "Error: no horizons configured; cannot export chart.\n");
return;
}
const last_idx_ec = horizons_ec.len - 1;
const bands_ec = ctx.data.bands[last_idx_ec] orelse {
try cli.stderrPrint(io, "Error: projection bands unavailable for the longest horizon; cannot export chart.\n");
return;
};
// Translate the view-layer overlay points (if any) into the
// chart-module's ActualsPoint shape. Same conversion the TUI
// does in `projections_tab.drawWithKittyChart`.
var overlay_buf: ?[]@import("../tui/projection_chart.zig").ActualsPoint = null;
defer if (overlay_buf) |ob| va.free(ob);
const overlay_input = blk: {
const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null);
const buf = va.alloc(@import("../tui/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null);
for (ov.points, 0..) |p, i| {
buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
}
overlay_buf = buf;
break :blk @import("../tui/projection_chart.zig").ActualsOverlay{
.points = buf,
.today_years = ov.today_years,
};
};
chart_export.exportProjectionChart(io, allocator, bands_ec, overlay_input, export_path) catch |err| switch (err) {
error.InsufficientData => {
try cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n");
return;
},
else => {
try cli.stderrPrint(io, "Error: failed to write PNG.\n");
return err;
},
};
return;
}
const horizons = ctx.config.getHorizons(); const horizons = ctx.config.getHorizons();
const confidence_levels = ctx.config.getConfidenceLevels(); const confidence_levels = ctx.config.getConfidenceLevels();
const comparison = ctx.comparison; const comparison = ctx.comparison;

View file

@ -4,9 +4,16 @@ const cli = @import("common.zig");
const framework = @import("framework.zig"); const framework = @import("framework.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
const Money = @import("../Money.zig"); const Money = @import("../Money.zig");
const chart_export = @import("../chart_export.zig");
const tui_chart = @import("../tui/chart.zig");
pub const ParsedArgs = struct { pub const ParsedArgs = struct {
symbol: []const u8, symbol: []const u8,
/// When set, render the chart as a PNG to this path instead of
/// emitting any text output. The chart code already produces
/// z2d-rendered pixel buffers for the TUI; this flag just lands
/// the same pixels in a file via z2d's PNG exporter.
export_chart: ?[]const u8 = null,
}; };
pub const meta: framework.Meta = .{ pub const meta: framework.Meta = .{
@ -15,7 +22,7 @@ pub const meta: framework.Meta = .{
.synopsis = "Show latest quote with chart and 20-day history", .synopsis = "Show latest quote with chart and 20-day history",
.uppercase_first_arg = true, .uppercase_first_arg = true,
.help = .help =
\\Usage: zfin quote <SYMBOL> \\Usage: zfin quote <SYMBOL> [--export-chart <PATH>]
\\ \\
\\Show the latest real-time quote for a symbol (Yahoo / TwelveData) \\Show the latest real-time quote for a symbol (Yahoo / TwelveData)
\\plus a braille price chart of the last 60 candles and a table \\plus a braille price chart of the last 60 candles and a table
@ -25,12 +32,20 @@ pub const meta: framework.Meta = .{
\\Yahoo path is free and unauthenticated; TwelveData requires \\Yahoo path is free and unauthenticated; TwelveData requires
\\TWELVEDATA_API_KEY. \\TWELVEDATA_API_KEY.
\\ \\
\\Options:
\\ --export-chart <PATH> Render the price+Bollinger+RSI chart
\\ to a PNG file at the given path
\\ (1920x1080) and exit. No text output
\\ is emitted. Uses the TUI's default
\\ theme.
\\
\\Examples: \\Examples:
\\ zfin quote AAPL \\ zfin quote AAPL
\\ zfin quote spy # symbols are case-insensitive \\ zfin quote spy # symbols are case-insensitive
\\ zfin quote AAPL --export-chart aapl.png
\\ \\
, ,
.user_errors = error{ MissingSymbol, UnexpectedArg }, .user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue },
}; };
/// Quote data extracted from the real-time API (or synthesized from candles). /// Quote data extracted from the real-time API (or synthesized from candles).
@ -45,15 +60,38 @@ pub const QuoteData = struct {
}; };
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) { var symbol: ?[]const u8 = null;
var export_chart: ?[]const u8 = null;
var i: usize = 0;
while (i < cmd_args.len) : (i += 1) {
const a = cmd_args[i];
if (std.mem.eql(u8, a, "--export-chart")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --export-chart requires a path argument.\n");
return error.MissingFlagValue;
}
export_chart = cmd_args[i + 1];
i += 1;
} else if (std.mem.startsWith(u8, a, "--")) {
try cli.stderrPrint(ctx.io, "Error: 'quote': unexpected flag ");
try cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg;
} else {
if (symbol != null) {
try cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n");
return error.UnexpectedArg;
}
symbol = a;
}
}
if (symbol == null) {
try cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n"); try cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n");
return error.MissingSymbol; return error.MissingSymbol;
} }
if (cmd_args.len > 1) { return .{ .symbol = symbol.?, .export_chart = export_chart };
try cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
} }
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
@ -72,6 +110,31 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer candle_result.deinit(); defer candle_result.deinit();
const candles = candle_result.data; const candles = candle_result.data;
// PNG export short-circuits all text rendering. Use the
// longest timeframe the candle history can support falling
// back to shorter ones until one fits so the user gets the
// most chart context without having to think about it.
if (parsed.export_chart) |path| {
const tf: tui_chart.Timeframe = blk: {
const candidates = [_]tui_chart.Timeframe{ .@"5Y", .@"3Y", .@"1Y", .@"6M" };
for (candidates) |c| {
if (candles.len >= c.tradingDays()) break :blk c;
}
break :blk .@"6M"; // fallback; renderToSurface enforces >= 20 candles
};
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, tf, path) catch |err| switch (err) {
error.InsufficientData => {
try cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
return;
},
else => {
try cli.stderrPrint(ctx.io, "Error: failed to write PNG.\n");
return err;
},
};
return;
}
// Fetch real-time quote via DataService // Fetch real-time quote via DataService
var quote: ?QuoteData = null; var quote: ?QuoteData = null;
if (svc.getQuote(parsed.symbol)) |q| { if (svc.getQuote(parsed.symbol)) |q| {

View file

@ -178,7 +178,49 @@ pub fn computeIndicators(
/// Render a complete financial chart to raw RGB pixel data. /// Render a complete financial chart to raw RGB pixel data.
/// The returned rgb_data is allocated with `alloc` and must be freed by caller. /// The returned rgb_data is allocated with `alloc` and must be freed by caller.
/// If `cached` is provided, uses pre-computed indicators instead of recomputing. /// If `cached` is provided, uses pre-computed indicators instead of recomputing.
pub fn renderChart( /// Owned by the caller call `result.deinit(alloc)` after using it.
/// Used as the shared mid-stage between RGB extraction (kitty) and
/// PNG export (`--export-chart`). See `renderToSurface`.
pub const RenderedChart = struct {
surface: Surface,
width: u16,
height: u16,
price_min: f64,
price_max: f64,
rsi_latest: ?f64,
pub fn deinit(self: *RenderedChart, alloc: std.mem.Allocator) void {
self.surface.deinit(alloc);
self.* = undefined;
}
/// Extract a flat []u8 of R,G,B triplets from the surface buffer.
/// Caller owns the returned slice. The surface is left intact so
/// the caller can still call `deinit`.
pub fn extractRgb(self: *const RenderedChart, alloc: std.mem.Allocator) ![]u8 {
const rgb_buf = switch (self.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const raw = try alloc.alloc(u8, rgb_buf.len * 3);
for (rgb_buf, 0..) |px, i| {
raw[i * 3 + 0] = px.r;
raw[i * 3 + 1] = px.g;
raw[i * 3 + 2] = px.b;
}
return raw;
}
};
/// Render the chart into a `Surface` and return both. Caller owns
/// the result and must call `deinit`.
///
/// Two consumers today:
/// - `renderChart` wraps this for the TUI's kitty graphics path
/// (extracts RGB, frees surface).
/// - `--export-chart` (CLI) wraps this for PNG export via
/// `z2d.png_exporter.writeToPNGFile`.
pub fn renderToSurface(
io: std.Io, io: std.Io,
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
candles: []const zfin.Candle, candles: []const zfin.Candle,
@ -187,7 +229,7 @@ pub fn renderChart(
height_px: u32, height_px: u32,
th: theme.Theme, th: theme.Theme,
cached: ?*const CachedIndicators, cached: ?*const CachedIndicators,
) !ChartResult { ) !RenderedChart {
if (candles.len < 20) return error.InsufficientData; if (candles.len < 20) return error.InsufficientData;
// Slice candles to timeframe // Slice candles to timeframe
@ -230,7 +272,7 @@ pub fn renderChart(
const w: i32 = @intCast(width_px); const w: i32 = @intCast(width_px);
const h: i32 = @intCast(height_px); const h: i32 = @intCast(height_px);
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h); var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
defer sfc.deinit(alloc); errdefer sfc.deinit(alloc);
// Create drawing context // Create drawing context
var ctx = Context.init(io, alloc, &sfc); var ctx = Context.init(io, alloc, &sfc);
@ -464,22 +506,8 @@ pub fn renderChart(
} }
} }
// Extract raw RGB pixel data from the z2d surface buffer.
// The surface is image_surface_rgb, so the buffer is []pixel.RGB (packed u24).
// We need to convert to a flat []u8 of R,G,B triplets.
const rgb_buf = switch (sfc) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const pixel_count = rgb_buf.len;
const raw = try alloc.alloc(u8, pixel_count * 3);
for (rgb_buf, 0..) |px, i| {
raw[i * 3 + 0] = px.r;
raw[i * 3 + 1] = px.g;
raw[i * 3 + 2] = px.b;
}
return .{ return .{
.rgb_data = raw, .surface = sfc,
.width = @intCast(width_px), .width = @intCast(width_px),
.height = @intCast(height_px), .height = @intCast(height_px),
.price_min = price_min, .price_min = price_min,
@ -488,6 +516,32 @@ pub fn renderChart(
}; };
} }
/// Render a complete financial chart to raw RGB pixel data.
/// The returned rgb_data is allocated with `alloc` and must be freed by caller.
/// If `cached` is provided, uses pre-computed indicators instead of recomputing.
pub fn renderChart(
io: std.Io,
alloc: std.mem.Allocator,
candles: []const zfin.Candle,
timeframe: Timeframe,
width_px: u32,
height_px: u32,
th: theme.Theme,
cached: ?*const CachedIndicators,
) !ChartResult {
var rendered = try renderToSurface(io, alloc, candles, timeframe, width_px, height_px, th, cached);
defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc);
return .{
.rgb_data = raw,
.width = rendered.width,
.height = rendered.height,
.price_min = rendered.price_min,
.price_max = rendered.price_max,
.rsi_latest = rendered.rsi_latest,
};
}
// Drawing helpers // Drawing helpers
fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 { fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
@ -675,3 +729,182 @@ test "Timeframe tradingDays" {
try std.testing.expectEqual(@as(usize, 252), Timeframe.@"1Y".tradingDays()); try std.testing.expectEqual(@as(usize, 252), Timeframe.@"1Y".tradingDays());
try std.testing.expectEqual(@as(usize, 1260), Timeframe.@"5Y".tradingDays()); try std.testing.expectEqual(@as(usize, 1260), Timeframe.@"5Y".tradingDays());
} }
// renderToSurface tests
//
// These exercise the actual chart rendering pipeline (z2d surface +
// indicator computation) rather than just the helpers. They live in
// this file so they share the testing harness with the existing
// helper tests.
const test_alloc = std.testing.allocator;
const Date = @import("../Date.zig");
/// Build a slice of synthetic candles with linearly-rising prices.
/// Used by multiple renderToSurface tests.
fn buildLinearCandles(arr: []zfin.Candle, start_price: f64) void {
for (arr, 0..) |*c, i| {
const price: f64 = start_price + @as(f64, @floatFromInt(i));
c.* = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price + 1,
.low = price - 1,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
}
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);
try std.testing.expectError(error.InsufficientData, result);
}
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);
defer rendered.deinit(test_alloc);
try std.testing.expectEqual(@as(u16, 200), rendered.width);
try std.testing.expectEqual(@as(u16, 100), rendered.height);
// Surface must be the RGB variant the chart code commits to.
switch (rendered.surface) {
.image_surface_rgb => {},
else => try std.testing.expect(false),
}
}
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);
defer rendered.deinit(test_alloc);
// 5% padding is applied inside renderToSurface, so the recorded
// min must be <= the actual min close, and max >= actual max.
// Bollinger bands can also expand the range, so we check the
// weaker invariant rather than equality.
try std.testing.expect(rendered.price_min <= 100.0);
try std.testing.expect(rendered.price_max >= 129.0);
}
test "renderToSurface uses chartClose so split-day cliffs don't widen the price range" {
// 30 candles: first 15 have raw close=300, adj_close=100; last 15
// have raw close=100, adj_close=100. If renderToSurface used raw
// `close`, price_max would be ~315 (300 + padding); using
// `chartClose` it should stay near 100.
var candles: [30]zfin.Candle = undefined;
for (0..30) |i| {
const raw: f64 = if (i < 15) 300 else 100;
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = raw,
.high = raw,
.low = raw,
.close = raw,
.adj_close = 100, // adjusted is always 100
.volume = 1000,
};
}
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
defer rendered.deinit(test_alloc);
// With chartClose, max should be near 100 definitely not 250+.
// (Bollinger bands of a flat 100 series stay near 100, so the
// upper bound is tight.)
try std.testing.expect(rendered.price_max < 200);
}
test "renderToSurface fills background with theme bg" {
var candles: [30]zfin.Candle = undefined;
buildLinearCandles(&candles, 100.0);
// Use a custom theme with a distinctive bg color we can detect.
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);
defer rendered.deinit(test_alloc);
// Pixel at (0, 0) is in the top-left margin outside the chart
// area where lines/fills render so it should still be the
// background color.
const buf = switch (rendered.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
try std.testing.expect(buf.len > 0);
try std.testing.expectEqual(@as(u8, 0x12), buf[0].r);
try std.testing.expectEqual(@as(u8, 0x34), buf[0].g);
try std.testing.expectEqual(@as(u8, 0x56), buf[0].b);
}
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);
defer a.deinit(test_alloc);
var b = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
defer b.deinit(test_alloc);
const buf_a = switch (a.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const buf_b = switch (b.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
try std.testing.expectEqual(buf_a.len, buf_b.len);
// Compare a sample of pixels rather than the whole buffer to keep
// the failure message readable when this regresses.
var i: usize = 0;
while (i < buf_a.len) : (i += 100) {
try std.testing.expectEqual(buf_a[i].r, buf_b[i].r);
try std.testing.expectEqual(buf_a[i].g, buf_b[i].g);
try std.testing.expectEqual(buf_a[i].b, buf_b[i].b);
}
}
test "RenderedChart.extractRgb produces 3 bytes per pixel matching surface buffer" {
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);
defer rendered.deinit(test_alloc);
const raw = try rendered.extractRgb(test_alloc);
defer test_alloc.free(raw);
const buf = switch (rendered.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
try std.testing.expectEqual(buf.len * 3, raw.len);
// Spot-check first pixel: (R, G, B) at indices 0, 1, 2.
try std.testing.expectEqual(buf[0].r, raw[0]);
try std.testing.expectEqual(buf[0].g, raw[1]);
try std.testing.expectEqual(buf[0].b, raw[2]);
}
test "renderChart wraps renderToSurface and frees the surface" {
// Smoke check that the legacy `renderChart` path still works
// after the refactor same input shape, RGB extraction succeeds.
var candles: [30]zfin.Candle = undefined;
buildLinearCandles(&candles, 100.0);
const result = try renderChart(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
defer test_alloc.free(result.rgb_data);
try std.testing.expectEqual(@as(u16, 200), result.width);
try std.testing.expectEqual(@as(u16, 100), result.height);
try std.testing.expectEqual(@as(usize, 200 * 100 * 3), result.rgb_data.len);
}

View file

@ -59,13 +59,47 @@ pub const ProjectionChartResult = struct {
value_max: f64, value_max: f64,
}; };
/// Render a projection percentile band chart to raw RGB pixel data. /// Owned by the caller call `result.deinit(alloc)` after using it.
/// Draws p10-p90 outer band, p25-p75 inner band, and p50 median line. /// Used as the shared mid-stage between RGB extraction (kitty) and
/// PNG export (`--export-chart`). See `renderToSurface`.
pub const RenderedProjection = struct {
surface: Surface,
width: u16,
height: u16,
value_min: f64,
value_max: f64,
pub fn deinit(self: *RenderedProjection, alloc: std.mem.Allocator) void {
self.surface.deinit(alloc);
self.* = undefined;
}
/// Extract a flat []u8 of R,G,B triplets from the surface buffer.
/// Caller owns the returned slice. The surface is left intact.
pub fn extractRgb(self: *const RenderedProjection, alloc: std.mem.Allocator) ![]u8 {
const rgb_buf = switch (self.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const raw = try alloc.alloc(u8, rgb_buf.len * 3);
for (rgb_buf, 0..) |px, i| {
raw[i * 3 + 0] = px.r;
raw[i * 3 + 1] = px.g;
raw[i * 3 + 2] = px.b;
}
return raw;
}
};
/// Render a projection percentile band chart into a `Surface` and
/// return both. Caller owns the result and must call `deinit`.
/// ///
/// `bands` is the array of YearPercentiles (year 0 through horizon). /// Two consumers today:
/// `actuals` is an optional realized-trajectory overlay. /// - `renderProjectionChart` wraps this for the TUI's kitty
/// The returned rgb_data is allocated with `alloc` and must be freed by caller. /// graphics path (extracts RGB, frees surface).
pub fn renderProjectionChart( /// - `--export-chart` (CLI) wraps this for PNG export via
/// `z2d.png_exporter.writeToPNGFile`.
pub fn renderToSurface(
io: std.Io, io: std.Io,
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
bands: []const projections.YearPercentiles, bands: []const projections.YearPercentiles,
@ -73,13 +107,13 @@ pub fn renderProjectionChart(
height_px: u32, height_px: u32,
th: theme.Theme, th: theme.Theme,
actuals: ?ActualsOverlay, actuals: ?ActualsOverlay,
) !ProjectionChartResult { ) !RenderedProjection {
if (bands.len < 2) return error.InsufficientData; if (bands.len < 2) return error.InsufficientData;
const w: i32 = @intCast(width_px); const w: i32 = @intCast(width_px);
const h: i32 = @intCast(height_px); const h: i32 = @intCast(height_px);
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h); var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
defer sfc.deinit(alloc); errdefer sfc.deinit(alloc);
var ctx = Context.init(io, alloc, &sfc); var ctx = Context.init(io, alloc, &sfc);
defer ctx.deinit(); defer ctx.deinit();
@ -283,21 +317,8 @@ pub fn renderProjectionChart(
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, border_color, 1.0); try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, border_color, 1.0);
} }
// Extract raw RGB pixel data
const rgb_buf = switch (sfc) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const pixel_count = rgb_buf.len;
const raw = try alloc.alloc(u8, pixel_count * 3);
for (rgb_buf, 0..) |px, pi| {
raw[pi * 3 + 0] = px.r;
raw[pi * 3 + 1] = px.g;
raw[pi * 3 + 2] = px.b;
}
return .{ return .{
.rgb_data = raw, .surface = sfc,
.width = @intCast(width_px), .width = @intCast(width_px),
.height = @intCast(height_px), .height = @intCast(height_px),
.value_min = value_min, .value_min = value_min,
@ -305,6 +326,33 @@ pub fn renderProjectionChart(
}; };
} }
/// Render a projection percentile band chart to raw RGB pixel data.
/// Draws p10-p90 outer band, p25-p75 inner band, and p50 median line.
///
/// `bands` is the array of YearPercentiles (year 0 through horizon).
/// `actuals` is an optional realized-trajectory overlay.
/// The returned rgb_data is allocated with `alloc` and must be freed by caller.
pub fn renderProjectionChart(
io: std.Io,
alloc: std.mem.Allocator,
bands: []const projections.YearPercentiles,
width_px: u32,
height_px: u32,
th: theme.Theme,
actuals: ?ActualsOverlay,
) !ProjectionChartResult {
var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals);
defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc);
return .{
.rgb_data = raw,
.width = rendered.width,
.height = rendered.height,
.value_min = rendered.value_min,
.value_max = rendered.value_max,
};
}
// Drawing helpers // Drawing helpers
fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 { fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
@ -493,3 +541,121 @@ test "renderProjectionChart overlay with no points renders without crash" {
defer alloc.free(result.rgb_data); defer alloc.free(result.rgb_data);
try std.testing.expect(result.rgb_data.len > 0); try std.testing.expect(result.rgb_data.len > 0);
} }
// renderToSurface direct tests
//
// Exercise the surface-builder seam introduced for --export-chart.
// Verifies the surface is the right type/dimensions and that
// extractRgb round-trips correctly.
test "renderToSurface returns a populated RGB surface at requested dimensions" {
const alloc = std.testing.allocator;
const bands = [_]projections.YearPercentiles{
.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
};
const th = @import("theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null);
defer rendered.deinit(alloc);
try std.testing.expectEqual(@as(u16, 150), rendered.width);
try std.testing.expectEqual(@as(u16, 80), rendered.height);
switch (rendered.surface) {
.image_surface_rgb => {},
else => try std.testing.expect(false),
}
}
test "renderToSurface fills background with theme bg" {
const alloc = std.testing.allocator;
const bands = [_]projections.YearPercentiles{
.{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 },
.{ .year = 1, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 },
};
var th = @import("theme.zig").default_theme;
th.bg = .{ 0xab, 0xcd, 0xef };
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null);
defer rendered.deinit(alloc);
const buf = switch (rendered.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
try std.testing.expectEqual(@as(u8, 0xab), buf[0].r);
try std.testing.expectEqual(@as(u8, 0xcd), buf[0].g);
try std.testing.expectEqual(@as(u8, 0xef), buf[0].b);
}
test "renderToSurface is deterministic across calls with same input" {
const alloc = std.testing.allocator;
const bands = [_]projections.YearPercentiles{
.{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 },
.{ .year = 5, .p10 = 90, .p25 = 110, .p50 = 130, .p75 = 160, .p90 = 200 },
.{ .year = 10, .p10 = 80, .p25 = 120, .p50 = 160, .p75 = 220, .p90 = 300 },
};
const th = @import("theme.zig").default_theme;
var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
defer a.deinit(alloc);
var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
defer b.deinit(alloc);
const buf_a = switch (a.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const buf_b = switch (b.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
try std.testing.expectEqual(buf_a.len, buf_b.len);
var i: usize = 0;
while (i < buf_a.len) : (i += 50) {
try std.testing.expectEqual(buf_a[i].r, buf_b[i].r);
try std.testing.expectEqual(buf_a[i].g, buf_b[i].g);
try std.testing.expectEqual(buf_a[i].b, buf_b[i].b);
}
}
test "RenderedProjection.extractRgb produces 3 bytes per pixel" {
const alloc = std.testing.allocator;
const bands = [_]projections.YearPercentiles{
.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
};
const th = @import("theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null);
defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc);
defer alloc.free(raw);
const buf = switch (rendered.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
try std.testing.expectEqual(buf.len * 3, raw.len);
try std.testing.expectEqual(buf[0].r, raw[0]);
try std.testing.expectEqual(buf[0].g, raw[1]);
try std.testing.expectEqual(buf[0].b, raw[2]);
}
test "renderToSurface clamps value_min to zero when bands include negatives" {
const alloc = std.testing.allocator;
// p10 dipping below zero with no padding clamping would make
// the chart's negative region uselessly large.
const bands = [_]projections.YearPercentiles{
.{ .year = 0, .p10 = -100, .p25 = -50, .p50 = 0, .p75 = 50, .p90 = 100 },
.{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 },
};
const th = @import("theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
defer rendered.deinit(alloc);
// After 5% padding and the `if (value_min < 0) value_min = 0`
// clamp inside renderToSurface, the recorded min should be 0
// even though the raw band min is negative.
try std.testing.expectEqual(@as(f64, 0), rendered.value_min);
try std.testing.expect(rendered.value_max > 0);
}