304 lines
11 KiB
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));
|
|
}
|