//! Financial chart renderer using z2d. //! Renders price + Bollinger Bands, volume bars, and RSI panel to raw RGB pixel data //! suitable for Kitty graphics protocol transmission. const std = @import("std"); const z2d = @import("z2d"); const zfin = @import("../root.zig"); const theme = @import("../tui/theme.zig"); const draw = @import("draw.zig"); const Surface = z2d.Surface; /// Chart rendering mode. pub const ChartMode = enum { /// Auto-detect: use Kitty graphics if terminal supports it, otherwise braille. auto, /// Force braille chart (no pixel graphics). braille, /// Kitty graphics with a custom resolution cap (width x height). kitty, }; /// Chart graphics configuration. pub const ChartConfig = struct { mode: ChartMode = .auto, max_width: u32 = 1920, max_height: u32 = 1080, /// Parse a --chart argument value. /// Accepted formats: /// "auto" - auto-detect (default) /// "braille" - force braille /// "WxH" - Kitty graphics with custom resolution (e.g. "1920x1080") pub fn parse(value: []const u8) ?ChartConfig { if (std.mem.eql(u8, value, "auto")) return .{ .mode = .auto }; if (std.mem.eql(u8, value, "braille")) return .{ .mode = .braille }; // Try WxH format if (std.mem.indexOfScalar(u8, value, 'x')) |sep| { const w = std.fmt.parseInt(u32, value[0..sep], 10) catch return null; const h = std.fmt.parseInt(u32, value[sep + 1 ..], 10) catch return null; if (w < 100 or h < 100) return null; return .{ .mode = .kitty, .max_width = w, .max_height = h }; } return null; } }; const Context = z2d.Context; const Pixel = z2d.Pixel; /// Chart timeframe selection. pub const Timeframe = enum { @"6M", ytd, @"1Y", @"3Y", @"5Y", pub fn label(self: Timeframe) []const u8 { return switch (self) { .@"6M" => "6M", .ytd => "YTD", .@"1Y" => "1Y", .@"3Y" => "3Y", .@"5Y" => "5Y", }; } pub fn tradingDays(self: Timeframe) usize { return switch (self) { .@"6M" => 126, .ytd => 252, // approximation, we'll clamp .@"1Y" => 252, .@"3Y" => 756, .@"5Y" => 1260, }; } pub fn next(self: Timeframe) Timeframe { return switch (self) { .@"6M" => .ytd, .ytd => .@"1Y", .@"1Y" => .@"3Y", .@"3Y" => .@"5Y", .@"5Y" => .@"6M", }; } pub fn prev(self: Timeframe) Timeframe { return switch (self) { .@"6M" => .@"5Y", .ytd => .@"6M", .@"1Y" => .ytd, .@"3Y" => .@"1Y", .@"5Y" => .@"3Y", }; } }; /// Layout constants (fractions of total height). const price_frac: f64 = 0.72; // price panel takes 72% const rsi_frac: f64 = 0.20; // RSI panel takes 20% const gap_frac: f64 = 0.08; // gap between panels /// Margins in pixels. const margin_left: f64 = 4; const margin_right: f64 = 4; const margin_top: f64 = 4; const margin_bottom: f64 = 4; /// Chart render result - raw RGB pixel data ready for Kitty graphics transmission. pub const ChartResult = struct { /// Raw RGB pixel data (3 bytes per pixel, row-major). rgb_data: []const u8, width: u16, height: u16, /// Price range for external label rendering. price_min: f64, price_max: f64, /// Latest RSI value (or null if not enough data). rsi_latest: ?f64, }; /// Cached indicator data to avoid recomputation across frames. /// All slices are owned and must be freed with deinit(). pub const CachedIndicators = struct { closes: []f64, volumes: []f64, bb: []?zfin.indicators.BollingerBand, rsi_vals: []?f64, /// Free all allocated memory. pub fn deinit(self: *CachedIndicators, alloc: std.mem.Allocator) void { alloc.free(self.closes); alloc.free(self.volumes); alloc.free(self.bb); alloc.free(self.rsi_vals); self.* = undefined; } }; /// Compute indicators for the given candle data. /// Returns owned CachedIndicators that must be freed with deinit(). pub fn computeIndicators( alloc: std.mem.Allocator, candles: []const zfin.Candle, timeframe: Timeframe, ) !CachedIndicators { if (candles.len < 20) return error.InsufficientData; // Slice candles to timeframe const max_days = timeframe.tradingDays(); const n = @min(candles.len, max_days); const data = candles[candles.len - n ..]; // Extract data series const closes = try zfin.indicators.closePrices(alloc, data); errdefer alloc.free(closes); const vols = try zfin.indicators.volumes(alloc, data); errdefer alloc.free(vols); // Compute indicators const bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0); errdefer alloc.free(bb); const rsi_vals = try zfin.indicators.rsi(alloc, closes, 14); return .{ .closes = closes, .volumes = vols, .bb = bb, .rsi_vals = rsi_vals, }; } /// 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. /// 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 { return draw.extractRgb(alloc, &self.surface); } }; /// 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, timeframe: Timeframe, width_px: u32, height_px: u32, th: theme.Theme, cached: ?*const CachedIndicators, ) !RenderedChart { if (candles.len < 20) return error.InsufficientData; // Slice candles to timeframe const max_days = timeframe.tradingDays(); const n = @min(candles.len, max_days); const data = candles[candles.len - n ..]; // Use cached indicators or compute fresh ones var local_closes: ?[]f64 = null; var local_vols: ?[]f64 = null; var local_bb: ?[]?zfin.indicators.BollingerBand = null; var local_rsi: ?[]?f64 = null; defer { if (local_closes) |c| alloc.free(c); if (local_vols) |v| alloc.free(v); if (local_bb) |b| alloc.free(b); if (local_rsi) |r| alloc.free(r); } const closes: []const f64 = if (cached) |c| c.closes else blk: { local_closes = try zfin.indicators.closePrices(alloc, data); break :blk local_closes.?; }; const vols: []const f64 = if (cached) |c| c.volumes else blk: { local_vols = try zfin.indicators.volumes(alloc, data); break :blk local_vols.?; }; const bb: []const ?zfin.indicators.BollingerBand = if (cached) |c| c.bb else blk: { local_bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0); break :blk local_bb.?; }; const rsi_vals: []const ?f64 = if (cached) |c| c.rsi_vals else blk: { local_rsi = try zfin.indicators.rsi(alloc, closes, 14); break :blk local_rsi.?; }; // Create z2d surface - use RGB (not RGBA) since we're rendering onto a solid // background. This avoids integer overflow in z2d's RGBA compositor when // compositing semi-transparent fills (alpha < 255). const w: i32 = @intCast(width_px); const h: i32 = @intCast(height_px); var sfc = try Surface.init(.image_surface_rgb, alloc, w, h); errdefer sfc.deinit(alloc); // Create drawing context var ctx = Context.init(io, alloc, &sfc); defer ctx.deinit(); // Disable anti-aliasing and use direct pixel writes (.source operator) // to avoid integer overflow bugs in z2d's src_over compositor. // Semi-transparent colors are pre-blended against bg in blendColor(). ctx.setAntiAliasingMode(.none); ctx.setOperator(.src); const bg = th.bg; const fwidth: f64 = @floatFromInt(width_px); const fheight: f64 = @floatFromInt(height_px); // Background try draw.fillBackground(&ctx, fwidth, fheight, bg); // Panel dimensions const chart_left = margin_left; const chart_right = fwidth - margin_right; const chart_w = chart_right - chart_left; const chart_top = margin_top; const total_h = fheight - margin_top - margin_bottom; const price_h = total_h * price_frac; const price_top = chart_top; const price_bottom = price_top + price_h; const gap_h = total_h * gap_frac; const rsi_h = total_h * rsi_frac; const rsi_top = price_bottom + gap_h; const rsi_bottom = rsi_top + rsi_h; // Price range (include Bollinger bands in range) var price_min: f64 = closes[0]; var price_max: f64 = closes[0]; for (closes) |c| { if (c < price_min) price_min = c; if (c > price_max) price_max = c; } for (bb) |b_opt| { if (b_opt) |b| { if (b.lower < price_min) price_min = b.lower; if (b.upper > price_max) price_max = b.upper; } } // Add 5% padding const price_pad = (price_max - price_min) * 0.05; price_min -= price_pad; price_max += price_pad; // Volume max var vol_max: f64 = 0; for (vols) |v| { if (v > vol_max) vol_max = v; } if (vol_max == 0) vol_max = 1; // Helper: map data index to x const x_step = chart_w / @as(f64, @floatFromInt(data.len - 1)); // ── Grid lines ──────────────────────────────────────────────────── const grid_color = blendColor(th.text_muted, 60, bg); try drawHorizontalGridLines(&ctx, chart_left, chart_right, price_top, price_bottom, 5, grid_color); try drawHorizontalGridLines(&ctx, chart_left, chart_right, rsi_top, rsi_bottom, 4, grid_color); // ── Volume bars (overlaid on price panel bottom 25%) ───────────── { const vol_panel_h = price_h * 0.25; const vol_bottom_y = price_bottom; const bar_w = @max(x_step * 0.7, 1.0); for (data, 0..) |candle, ci| { const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; const vol_h_px = (vols[ci] / vol_max) * vol_panel_h; const bar_top = vol_bottom_y - vol_h_px; // Up/down coloring: compare today's chart-close to // yesterday's chart-close (both split-adjusted when // available). Comparing close-vs-open here would render // a spurious "down" day on every split date because // `Candle.open` is not split-adjusted but `chartClose` // is - see `Candle.chartClose` for context. const cc = candle.chartClose(); const prev_cc = if (ci > 0) data[ci - 1].chartClose() else cc; const is_up = cc >= prev_cc; const col = if (is_up) blendColor(th.positive, 50, bg) else blendColor(th.negative, 50, bg); ctx.setSourceToPixel(col); ctx.resetPath(); try ctx.moveTo(x - bar_w / 2, bar_top); try ctx.lineTo(x + bar_w / 2, bar_top); try ctx.lineTo(x + bar_w / 2, vol_bottom_y); try ctx.lineTo(x - bar_w / 2, vol_bottom_y); try ctx.closePath(); try ctx.fill(); } } // ── Bollinger Bands fill (drawn FIRST so price fill paints over it) ── { const band_fill_color = blendColor(th.accent, 25, bg); ctx.setSourceToPixel(band_fill_color); ctx.resetPath(); var started = false; for (bb, 0..) |b_opt, ci| { if (b_opt) |b| { const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; const y = mapY(b.upper, price_min, price_max, price_top, price_bottom); if (!started) { try ctx.moveTo(x, y); started = true; } else { try ctx.lineTo(x, y); } } } if (started) { var ci: usize = data.len; while (ci > 0) { ci -= 1; if (bb[ci]) |b| { const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; const y = mapY(b.lower, price_min, price_max, price_top, price_bottom); try ctx.lineTo(x, y); } } try ctx.closePath(); try ctx.fill(); } } // ── Price filled area (on top of BB fill) ────────────────────────── { const start_price = closes[0]; const end_price = closes[closes.len - 1]; const fill_color = if (end_price >= start_price) blendColor(th.positive, 30, bg) else blendColor(th.negative, 30, bg); ctx.setSourceToPixel(fill_color); ctx.resetPath(); for (closes, 0..) |c, ci| { const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; const y = mapY(c, price_min, price_max, price_top, price_bottom); if (ci == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); } const last_x = chart_left + @as(f64, @floatFromInt(closes.len - 1)) * x_step; try ctx.lineTo(last_x, price_bottom); try ctx.lineTo(chart_left, price_bottom); try ctx.closePath(); try ctx.fill(); } // ── Bollinger Band boundary lines + SMA (on top of fills) ────────── { const band_line_color = blendColor(th.text_muted, 100, bg); try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .upper); try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .lower); // SMA (middle) try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, blendColor(th.text_muted, 160, bg), 1.0, .middle); } // ── Price line (on top of everything) ───────────────────────────── { const start_price = closes[0]; const end_price = closes[closes.len - 1]; const price_color = if (end_price >= start_price) opaqueColor(th.positive) else opaqueColor(th.negative); ctx.setSourceToPixel(price_color); ctx.setLineWidth(2.0); ctx.resetPath(); for (closes, 0..) |c, ci| { const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; const y = mapY(c, price_min, price_max, price_top, price_bottom); if (ci == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); } try ctx.stroke(); } // ── RSI panel ───────────────────────────────────────────────────── { const ref_color = blendColor(th.text_muted, 100, bg); try drawHLine(&ctx, chart_left, chart_right, mapY(70, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0); try drawHLine(&ctx, chart_left, chart_right, mapY(30, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0); try drawHLine(&ctx, chart_left, chart_right, mapY(50, 0, 100, rsi_top, rsi_bottom), blendColor(th.text_muted, 50, bg), 1.0); const rsi_color = blendColor(th.info, 220, bg); ctx.setSourceToPixel(rsi_color); ctx.setLineWidth(1.5); ctx.resetPath(); var rsi_started = false; for (rsi_vals, 0..) |r_opt, ci| { if (r_opt) |r| { const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; const y = mapY(r, 0, 100, rsi_top, rsi_bottom); if (!rsi_started) { try ctx.moveTo(x, y); rsi_started = true; } else { try ctx.lineTo(x, y); } } } if (rsi_started) try ctx.stroke(); } // ── Panel borders ───────────────────────────────────────────────── { const border_color = blendColor(th.border, 80, bg); try drawRect(&ctx, chart_left, price_top, chart_right, price_bottom, border_color, 1.0); try drawRect(&ctx, chart_left, rsi_top, chart_right, rsi_bottom, border_color, 1.0); } // Get latest RSI var rsi_latest: ?f64 = null; { var ri: usize = rsi_vals.len; while (ri > 0) { ri -= 1; if (rsi_vals[ri]) |r| { rsi_latest = r; break; } } } return .{ .surface = sfc, .width = @intCast(width_px), .height = @intCast(height_px), .price_min = price_min, .price_max = price_max, .rsi_latest = rsi_latest, }; } /// 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 ─────────────────────────────────────────────────── // // The stateless primitives below are shared with the other chart // renderers and live in `draw.zig`; aliased here so the call sites in // this file stay unchanged. const mapY = draw.mapY; const blendColor = draw.blendColor; const opaqueColor = draw.opaqueColor; const BandField = enum { upper, middle, lower }; fn drawLineSeries( ctx: *Context, bb: []const ?zfin.indicators.BollingerBand, len: usize, price_min: f64, price_max: f64, price_top: f64, price_bottom: f64, chart_left: f64, x_step: f64, col: Pixel, line_w: f64, field: BandField, ) !void { ctx.setSourceToPixel(col); ctx.setLineWidth(line_w); ctx.resetPath(); var started = false; for (0..len) |i| { if (bb[i]) |b| { const val = switch (field) { .upper => b.upper, .middle => b.middle, .lower => b.lower, }; const x = chart_left + @as(f64, @floatFromInt(i)) * x_step; const y = mapY(val, price_min, price_max, price_top, price_bottom); if (!started) { try ctx.moveTo(x, y); started = true; } else { try ctx.lineTo(x, y); } } } if (started) try ctx.stroke(); ctx.setLineWidth(2.0); } const drawHorizontalGridLines = draw.drawHorizontalGridLines; const drawHLine = draw.drawHLine; const drawRect = draw.drawRect; // ── Tests ───────────────────────────────────────────────────────────── test "ChartConfig.parse" { // Named modes const auto = ChartConfig.parse("auto").?; try std.testing.expectEqual(ChartMode.auto, auto.mode); const braille = ChartConfig.parse("braille").?; try std.testing.expectEqual(ChartMode.braille, braille.mode); // WxH format const custom = ChartConfig.parse("800x600").?; try std.testing.expectEqual(ChartMode.kitty, custom.mode); try std.testing.expectEqual(@as(u32, 800), custom.max_width); try std.testing.expectEqual(@as(u32, 600), custom.max_height); // Too small try std.testing.expectEqual(@as(?ChartConfig, null), ChartConfig.parse("50x50")); // Invalid try std.testing.expectEqual(@as(?ChartConfig, null), ChartConfig.parse("garbage")); } test "Timeframe next/prev cycle" { // next cycles through all values try std.testing.expectEqual(Timeframe.ytd, Timeframe.@"6M".next()); try std.testing.expectEqual(Timeframe.@"1Y", Timeframe.ytd.next()); try std.testing.expectEqual(Timeframe.@"6M", Timeframe.@"5Y".next()); // wraps // prev is the reverse try std.testing.expectEqual(Timeframe.@"5Y", Timeframe.@"6M".prev()); // wraps try std.testing.expectEqual(Timeframe.@"6M", Timeframe.ytd.prev()); } test "Timeframe tradingDays" { try std.testing.expectEqual(@as(usize, 126), Timeframe.@"6M".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); }