zfin/src/charts/draw.zig

304 lines
11 KiB
Zig

//! 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));
}