export chart as png
This commit is contained in:
parent
5ab2600946
commit
d3b1c04a3a
7 changed files with 878 additions and 77 deletions
11
README.md
11
README.md
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
68
TODO.md
68
TODO.md
|
|
@ -102,37 +102,47 @@ ranking; unlabeled items are "someday, if the mood strikes."
|
|||
faithfulness one notch. Pick whichever has the highest
|
||||
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
|
||||
commands (`quote`, `history`, `projections`, plus the equivalent TUI
|
||||
tabs) render to braille (CLI) or Kitty graphics (TUI). Adding a
|
||||
`--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.
|
||||
V1 of `--export-chart <PATH>` shipped for `quote` and `projections`
|
||||
(default bands mode only). Several adjacent surfaces still don't
|
||||
have PNG export and were deferred:
|
||||
|
||||
Driver: when reviewing a back-dated projection or a notable price
|
||||
move, capturing the chart as an image (e.g. for a write-up, an email
|
||||
to the household, or a wiki page) is currently a screenshot-and-crop
|
||||
chore. PNG export makes it a one-shot CLI invocation.
|
||||
|
||||
Sketch:
|
||||
- `zfin quote AAPL --export-chart aapl.png` → just the price+
|
||||
Bollinger chart as a PNG, no other output.
|
||||
- `zfin projections --as-of 1Y --overlay-actuals --export-chart projection.png`
|
||||
→ the projection-bands chart plus overlay, no other output.
|
||||
- The chart code already produces RGB pixel buffers via z2d; replace
|
||||
the `transmitPreEncodedImage` call (TUI) or the braille text path
|
||||
(CLI) with a `Surface.write_png` call when the flag is present.
|
||||
|
||||
Plumbing: a thin "chart-only render" entry point in each chart
|
||||
module (`projection_chart.zig`, `chart.zig` for symbols), called
|
||||
from the relevant command's `run()` when `--export-chart` is set.
|
||||
Exits before the rest of the text output renders.
|
||||
|
||||
Out of scope for V1: file-format alternatives (SVG, PDF), themed
|
||||
color overrides for export (always uses the active terminal theme),
|
||||
non-chart command output as PNG.
|
||||
- **`history --export-chart`.** The `history` command renders a
|
||||
single-series braille chart of portfolio value over time
|
||||
(synthesized into `Candle` records and fed to
|
||||
`format.computeBrailleChart`). It doesn't share the z2d
|
||||
pipeline that `quote` (`tui/chart.zig`) and `projections`
|
||||
(`tui/projection_chart.zig`) use. To export, options:
|
||||
- **A.** Pipe the synthesized candles through
|
||||
`tui/chart.zig`'s `renderChart` — but that draws Bollinger
|
||||
Bands and an RSI panel, both meaningless on a portfolio-
|
||||
value series.
|
||||
- **B.** Add a minimal "single-series line chart" z2d
|
||||
renderer (a slimmed-down `projection_chart.zig` without
|
||||
bands). ~150 lines. Same renderToSurface shape so PNG
|
||||
export is trivial after.
|
||||
- **C.** Skip it permanently; the braille chart is fine for
|
||||
what `history` is. Document as "not exportable".
|
||||
B is the right answer if PNG export of the history chart is
|
||||
ever requested.
|
||||
- **`projections --convergence` / `--return-backtest`.** Both
|
||||
render forecast-evaluation charts via `tui/forecast_chart.zig`.
|
||||
Not refactored to expose a `renderToSurface` seam yet —
|
||||
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
|
||||
|
||||
|
|
|
|||
236
src/chart_export.zig
Normal file
236
src/chart_export.zig
Normal 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),
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ const imported = @import("../data/imported_values.zig");
|
|||
const forecast = @import("../analytics/forecast_evaluation.zig");
|
||||
const milestones = @import("../analytics/milestones.zig");
|
||||
const shiller = @import("../data/shiller.zig");
|
||||
const chart_export = @import("../chart_export.zig");
|
||||
|
||||
/// Hardcoded benchmark symbols (configurable in a future version).
|
||||
const stock_benchmark = "SPY";
|
||||
|
|
@ -55,6 +56,11 @@ pub const BandsArgs = struct {
|
|||
/// `null` means live (today). Non-null = historical snapshot.
|
||||
as_of: ?Date = null,
|
||||
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 {
|
||||
|
|
@ -103,6 +109,11 @@ pub const meta: framework.Meta = .{
|
|||
\\ --return-backtest (see above)
|
||||
\\ --real With --return-backtest, render in
|
||||
\\ 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).
|
||||
\\
|
||||
|
|
@ -122,6 +133,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
var convergence = false;
|
||||
var return_backtest = false;
|
||||
var real_mode = false;
|
||||
var export_chart: ?[]const u8 = null;
|
||||
|
||||
var i: usize = 0;
|
||||
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;
|
||||
} else if (std.mem.eql(u8, a, "--real")) {
|
||||
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")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
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");
|
||||
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 (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,
|
||||
.as_of = as_of,
|
||||
.overlay_actuals = overlay_actuals,
|
||||
.export_chart = export_chart,
|
||||
} };
|
||||
}
|
||||
|
||||
|
|
@ -256,6 +284,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
.today = today,
|
||||
.overlay_actuals = args.overlay_actuals,
|
||||
.refresh = ctx.globals.refresh_policy,
|
||||
.export_chart = args.export_chart,
|
||||
},
|
||||
color,
|
||||
out,
|
||||
|
|
@ -319,6 +348,10 @@ pub const BandsOptions = struct {
|
|||
/// Refresh policy threaded through the live "now" price
|
||||
/// load. Has no effect when `from_snapshot = true`.
|
||||
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(
|
||||
|
|
@ -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 confidence_levels = ctx.config.getConfidenceLevels();
|
||||
const comparison = ctx.comparison;
|
||||
|
|
|
|||
|
|
@ -4,9 +4,16 @@ const cli = @import("common.zig");
|
|||
const framework = @import("framework.zig");
|
||||
const fmt = cli.fmt;
|
||||
const Money = @import("../Money.zig");
|
||||
const chart_export = @import("../chart_export.zig");
|
||||
const tui_chart = @import("../tui/chart.zig");
|
||||
|
||||
pub const ParsedArgs = struct {
|
||||
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 = .{
|
||||
|
|
@ -15,7 +22,7 @@ pub const meta: framework.Meta = .{
|
|||
.synopsis = "Show latest quote with chart and 20-day history",
|
||||
.uppercase_first_arg = true,
|
||||
.help =
|
||||
\\Usage: zfin quote <SYMBOL>
|
||||
\\Usage: zfin quote <SYMBOL> [--export-chart <PATH>]
|
||||
\\
|
||||
\\Show the latest real-time quote for a symbol (Yahoo / TwelveData)
|
||||
\\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
|
||||
\\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:
|
||||
\\ zfin quote AAPL
|
||||
\\ 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).
|
||||
|
|
@ -45,15 +60,38 @@ pub const QuoteData = struct {
|
|||
};
|
||||
|
||||
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");
|
||||
return error.MissingSymbol;
|
||||
}
|
||||
if (cmd_args.len > 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
return .{ .symbol = cmd_args[0] };
|
||||
return .{ .symbol = symbol.?, .export_chart = export_chart };
|
||||
}
|
||||
|
||||
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();
|
||||
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
|
||||
var quote: ?QuoteData = null;
|
||||
if (svc.getQuote(parsed.symbol)) |q| {
|
||||
|
|
|
|||
|
|
@ -178,7 +178,49 @@ pub fn computeIndicators(
|
|||
/// 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(
|
||||
/// 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,
|
||||
alloc: std.mem.Allocator,
|
||||
candles: []const zfin.Candle,
|
||||
|
|
@ -187,7 +229,7 @@ pub fn renderChart(
|
|||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
cached: ?*const CachedIndicators,
|
||||
) !ChartResult {
|
||||
) !RenderedChart {
|
||||
if (candles.len < 20) return error.InsufficientData;
|
||||
|
||||
// Slice candles to timeframe
|
||||
|
|
@ -230,7 +272,7 @@ pub fn renderChart(
|
|||
const w: i32 = @intCast(width_px);
|
||||
const h: i32 = @intCast(height_px);
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
|
||||
defer sfc.deinit(alloc);
|
||||
errdefer sfc.deinit(alloc);
|
||||
|
||||
// Create drawing context
|
||||
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 .{
|
||||
.rgb_data = raw,
|
||||
.surface = sfc,
|
||||
.width = @intCast(width_px),
|
||||
.height = @intCast(height_px),
|
||||
.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 ───────────────────────────────────────────────────
|
||||
|
||||
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, 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,13 +59,47 @@ pub const ProjectionChartResult = struct {
|
|||
value_max: f64,
|
||||
};
|
||||
|
||||
/// Render a projection percentile band chart to raw RGB pixel data.
|
||||
/// Draws p10-p90 outer band, p25-p75 inner band, and p50 median line.
|
||||
/// 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 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).
|
||||
/// `actuals` is an optional realized-trajectory overlay.
|
||||
/// The returned rgb_data is allocated with `alloc` and must be freed by caller.
|
||||
pub fn renderProjectionChart(
|
||||
/// Two consumers today:
|
||||
/// - `renderProjectionChart` 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,
|
||||
alloc: std.mem.Allocator,
|
||||
bands: []const projections.YearPercentiles,
|
||||
|
|
@ -73,13 +107,13 @@ pub fn renderProjectionChart(
|
|||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
actuals: ?ActualsOverlay,
|
||||
) !ProjectionChartResult {
|
||||
) !RenderedProjection {
|
||||
if (bands.len < 2) return error.InsufficientData;
|
||||
|
||||
const w: i32 = @intCast(width_px);
|
||||
const h: i32 = @intCast(height_px);
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
|
||||
defer sfc.deinit(alloc);
|
||||
errdefer sfc.deinit(alloc);
|
||||
|
||||
var ctx = Context.init(io, alloc, &sfc);
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 .{
|
||||
.rgb_data = raw,
|
||||
.surface = sfc,
|
||||
.width = @intCast(width_px),
|
||||
.height = @intCast(height_px),
|
||||
.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 ───────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue