//! Shared z2d drawing primitives for the chart renderers in //! `src/charts/` (chart.zig, projection_chart.zig, forecast_chart.zig, //! line_chart.zig). //! //! These helpers were copy-pasted verbatim across all four renderers //! before this module existed; they're consolidated here so there is a //! single source of truth. Everything here is a pure, stateless z2d //! operation parameterized by pixel coordinates and pre-blended colors: //! no theme, domain, or chart-shape knowledge lives here. //! //! The Surface/Context lifetime (the `Surface.init` + `errdefer`/`defer //! deinit` dance and the AA/operator setup) deliberately stays with //! each renderer - that ownership does not extract cleanly - so only //! the stateless drawing belongs in this module. const std = @import("std"); const z2d = @import("z2d"); const Surface = z2d.Surface; const Context = z2d.Context; const Pixel = z2d.Pixel; /// Map a data value to a y pixel coordinate within `[top_px, bottom_px]`. /// A larger value maps nearer `top_px` (screen space grows downward). A /// degenerate (`min_val == max_val`) range maps to the vertical midpoint. pub fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 { if (max_val == min_val) return (top_px + bottom_px) / 2; const norm = (value - min_val) / (max_val - min_val); return bottom_px - norm * (bottom_px - top_px); } /// Pre-blend a foreground color with `alpha` against a background color /// and return a fully opaque pixel. This sidesteps z2d's src_over /// compositor (which overflows on semi-transparent fills); renderers /// draw with the `.src` operator and pre-blend through here instead. pub fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel { const a = @as(f64, @floatFromInt(alpha)) / 255.0; const inv_a = 1.0 - a; return .{ .rgb = .{ .r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a), .g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a), .b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a), } }; } /// Opaque pixel from an RGB triple. pub fn opaqueColor(c: [3]u8) Pixel { return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } }; } /// Fill the whole `width` x `height` surface with an opaque background. /// Mirrors the "Background" layer every renderer paints first. pub fn fillBackground(ctx: *Context, width: f64, height: f64, bg: [3]u8) !void { ctx.setSourceToPixel(opaqueColor(bg)); ctx.resetPath(); try ctx.moveTo(0, 0); try ctx.lineTo(width, 0); try ctx.lineTo(width, height); try ctx.lineTo(0, height); try ctx.closePath(); try ctx.fill(); } /// Draw `n_lines - 1` evenly-spaced horizontal grid lines strictly /// between `top` and `bottom` (the edges themselves are left to the /// panel border). Restores the line width to 2.0 when done so callers /// can keep drawing without re-setting it. pub fn drawHorizontalGridLines( ctx: *Context, left: f64, right: f64, top: f64, bottom: f64, n_lines: usize, col: Pixel, ) !void { ctx.setSourceToPixel(col); ctx.setLineWidth(0.5); for (1..n_lines) |i| { const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines)); const y = top + frac * (bottom - top); ctx.resetPath(); try ctx.moveTo(left, y); try ctx.lineTo(right, y); try ctx.stroke(); } ctx.setLineWidth(2.0); } /// Draw a horizontal line at `y` from `x1` to `x2`. Restores the line /// width to 2.0 afterward. pub fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, line_w: f64) !void { ctx.setSourceToPixel(col); ctx.setLineWidth(line_w); ctx.resetPath(); try ctx.moveTo(x1, y); try ctx.lineTo(x2, y); try ctx.stroke(); ctx.setLineWidth(2.0); } /// Draw a vertical line at `x` from `y1` to `y2`. Restores the line /// width to 2.0 afterward. pub fn drawVLine(ctx: *Context, x: f64, y1: f64, y2: f64, col: Pixel, line_w: f64) !void { ctx.setSourceToPixel(col); ctx.setLineWidth(line_w); ctx.resetPath(); try ctx.moveTo(x, y1); try ctx.lineTo(x, y2); try ctx.stroke(); ctx.setLineWidth(2.0); } /// Stroke an axis-aligned rectangle outline. Restores the line width to /// 2.0 afterward. pub fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, line_w: f64) !void { ctx.setSourceToPixel(col); ctx.setLineWidth(line_w); ctx.resetPath(); try ctx.moveTo(x1, y1); try ctx.lineTo(x2, y1); try ctx.lineTo(x2, y2); try ctx.lineTo(x1, y2); try ctx.closePath(); try ctx.stroke(); ctx.setLineWidth(2.0); } /// Extract a flat `[]u8` of R,G,B triplets from an `image_surface_rgb`. /// Caller owns the returned slice; the surface is left intact. Every /// renderer transmits/encodes pixels through this same shape (Kitty /// graphics RGB and z2d PNG export both want tightly-packed RGB bytes). pub fn extractRgb(alloc: std.mem.Allocator, sfc: *const Surface) ![]u8 { const rgb_buf = switch (sfc.*) { .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; } // ── Tests ───────────────────────────────────────────────────────────── const testing = std.testing; /// Build a fresh RGB drawing context backed by `sfc`, configured the way /// every renderer configures it (AA off, `.src` operator). Caller owns /// both and must `ctx.deinit()` / `sfc.deinit(alloc)`. fn testContext(sfc: *Surface) Context { var ctx = Context.init(testing.io, testing.allocator, sfc); ctx.setAntiAliasingMode(.none); ctx.setOperator(.src); return ctx; } /// True if any pixel in the surface exactly matches the given RGB. fn surfaceHasColor(sfc: *const Surface, r: u8, g: u8, b: u8) bool { const buf = switch (sfc.*) { .image_surface_rgb => |s| s.buf, else => unreachable, }; for (buf) |px| { if (px.r == r and px.g == g and px.b == b) return true; } return false; } test "mapY maps value to pixel coordinate" { // value at min -> bottom try testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500)); // value at max -> top try testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500)); // value at midpoint -> midpoint try testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500)); // flat range -> midpoint try testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500)); } test "blendColor alpha blending" { const white = [3]u8{ 255, 255, 255 }; const black = [3]u8{ 0, 0, 0 }; // Full alpha -> foreground. const full = blendColor(white, 255, black); try testing.expectEqual(@as(u8, 255), full.rgb.r); try testing.expectEqual(@as(u8, 255), full.rgb.g); try testing.expectEqual(@as(u8, 255), full.rgb.b); // Zero alpha -> background. const zero = blendColor(white, 0, black); try testing.expectEqual(@as(u8, 0), zero.rgb.r); // Half alpha -> midpoint (255 * 128/255 ~= 128). const half = blendColor(white, 128, black); try testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129); // Zero alpha blends toward a non-black background, not just black. const onto_gray = blendColor(white, 0, .{ 40, 50, 60 }); try testing.expectEqual(@as(u8, 40), onto_gray.rgb.r); try testing.expectEqual(@as(u8, 50), onto_gray.rgb.g); try testing.expectEqual(@as(u8, 60), onto_gray.rgb.b); } test "opaqueColor wraps an RGB triple" { const px = opaqueColor(.{ 0x7f, 0xd8, 0x8f }); try testing.expectEqual(@as(u8, 0x7f), px.rgb.r); try testing.expectEqual(@as(u8, 0xd8), px.rgb.g); try testing.expectEqual(@as(u8, 0x8f), px.rgb.b); } test "fillBackground paints every pixel the bg color" { const alloc = testing.allocator; var sfc = try Surface.init(.image_surface_rgb, alloc, 8, 6); defer sfc.deinit(alloc); var ctx = testContext(&sfc); defer ctx.deinit(); try fillBackground(&ctx, 8, 6, .{ 0x11, 0x22, 0x33 }); const buf = switch (sfc) { .image_surface_rgb => |s| s.buf, else => unreachable, }; for (buf) |px| { try testing.expectEqual(@as(u8, 0x11), px.r); try testing.expectEqual(@as(u8, 0x22), px.g); try testing.expectEqual(@as(u8, 0x33), px.b); } } test "extractRgb yields 3 interleaved bytes per pixel" { const alloc = testing.allocator; var sfc = try Surface.init(.image_surface_rgb, alloc, 4, 4); defer sfc.deinit(alloc); var ctx = testContext(&sfc); defer ctx.deinit(); try fillBackground(&ctx, 4, 4, .{ 0xde, 0xad, 0xbe }); const raw = try extractRgb(alloc, &sfc); defer alloc.free(raw); const buf = switch (sfc) { .image_surface_rgb => |s| s.buf, else => unreachable, }; try testing.expectEqual(buf.len * 3, raw.len); // First pixel round-trips as (R, G, B) at indices 0, 1, 2. try testing.expectEqual(@as(u8, 0xde), raw[0]); try testing.expectEqual(@as(u8, 0xad), raw[1]); try testing.expectEqual(@as(u8, 0xbe), raw[2]); } test "drawHLine strokes a line in the requested color" { const alloc = testing.allocator; var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 24); defer sfc.deinit(alloc); var ctx = testContext(&sfc); defer ctx.deinit(); try fillBackground(&ctx, 40, 24, .{ 0, 0, 0 }); try drawHLine(&ctx, 2, 38, 12, opaqueColor(.{ 0xff, 0x00, 0x00 }), 1.0); try testing.expect(surfaceHasColor(&sfc, 0xff, 0x00, 0x00)); } test "drawVLine strokes a line in the requested color" { const alloc = testing.allocator; var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 24); defer sfc.deinit(alloc); var ctx = testContext(&sfc); defer ctx.deinit(); try fillBackground(&ctx, 40, 24, .{ 0, 0, 0 }); try drawVLine(&ctx, 20, 2, 22, opaqueColor(.{ 0x00, 0xff, 0x00 }), 1.0); try testing.expect(surfaceHasColor(&sfc, 0x00, 0xff, 0x00)); } test "drawRect strokes a rectangle outline in the requested color" { const alloc = testing.allocator; var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 24); defer sfc.deinit(alloc); var ctx = testContext(&sfc); defer ctx.deinit(); try fillBackground(&ctx, 40, 24, .{ 0, 0, 0 }); try drawRect(&ctx, 4, 4, 36, 20, opaqueColor(.{ 0x00, 0x00, 0xff }), 1.0); try testing.expect(surfaceHasColor(&sfc, 0x00, 0x00, 0xff)); } test "drawHorizontalGridLines strokes lines in the requested color" { const alloc = testing.allocator; var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 40); defer sfc.deinit(alloc); var ctx = testContext(&sfc); defer ctx.deinit(); try fillBackground(&ctx, 40, 40, .{ 0, 0, 0 }); try drawHorizontalGridLines(&ctx, 2, 38, 2, 38, 5, opaqueColor(.{ 0x33, 0x66, 0x99 })); try testing.expect(surfaceHasColor(&sfc, 0x33, 0x66, 0x99)); }