lib/zen/src/diagram/domain.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 
  3 const model = @import("model.zig");
  4 const root = @import("root.zig");
  5 
  6 const spec = root.spec;
  7 const Bounds = model.Bounds;
  8 
  9 pub fn expand(min: *f64, max: *f64, kind: spec.ScaleKind, base: f64) void {
 10     if (min.* != max.*) return;
 11     switch (kind) {
 12         .linear => {
 13             min.* -= 0.5;
 14             max.* += 0.5;
 15         },
 16         .log => {
 17             const factor = @sqrt(base);
 18             min.* /= factor;
 19             max.* *= factor;
 20         },
 21     }
 22 }
 23 
 24 pub fn ratio(value: f64, min: f64, max: f64, kind: spec.ScaleKind, base: f64) !f64 {
 25     const scaled_value = try scaled(value, kind, base);
 26     const scaled_min = try scaled(min, kind, base);
 27     const scaled_max = try scaled(max, kind, base);
 28     return (scaled_value - scaled_min) / (scaled_max - scaled_min);
 29 }
 30 
 31 fn scaled(value: f64, kind: spec.ScaleKind, base: f64) !f64 {
 32     return switch (kind) {
 33         .linear => value,
 34         .log => if (value > 0) std.math.log(f64, base, value) else error.InvalidScale,
 35     };
 36 }
 37 
 38 pub const max_ticks = 6;
 39 const even_tick_count = 5;
 40 
 41 pub const Ticks = struct {
 42     values: [max_ticks]f64,
 43     count: usize,
 44 
 45     pub fn slice(self: *const Ticks) []const f64 {
 46         std.debug.assert(self.count <= max_ticks);
 47         return self.values[0..self.count];
 48     }
 49 };
 50 
 51 pub fn ticks(min: f64, max: f64, kind: spec.ScaleKind, base: f64) !Ticks {
 52     std.debug.assert(min < max);
 53     return switch (kind) {
 54         .linear => evenTicks(min, max, kind, base),
 55         .log => logTicks(min, max, base),
 56     };
 57 }
 58 
 59 fn evenTicks(min: f64, max: f64, kind: spec.ScaleKind, base: f64) !Ticks {
 60     var result = Ticks{ .values = undefined, .count = even_tick_count };
 61     const scaled_min = try scaled(min, kind, base);
 62     const scaled_max = try scaled(max, kind, base);
 63     for (result.values[0..even_tick_count], 0..) |*value, index| {
 64         const tick_ratio = @as(f64, @floatFromInt(index)) / (even_tick_count - 1);
 65         const placed = scaled_min + (scaled_max - scaled_min) * tick_ratio;
 66         value.* = switch (kind) {
 67             .linear => placed,
 68             .log => std.math.pow(f64, base, placed),
 69         };
 70     }
 71     return result;
 72 }
 73 
 74 fn logTicks(min: f64, max: f64, base: f64) !Ticks {
 75     std.debug.assert(min > 0);
 76     std.debug.assert(base > 1);
 77     const epsilon = 1e-9;
 78     const power_min: i64 = @intFromFloat(@ceil(std.math.log(f64, base, min) - epsilon));
 79     const power_max: i64 = @intFromFloat(@floor(std.math.log(f64, base, max) + epsilon));
 80     if (power_max < power_min + 1) return evenTicks(min, max, .log, base);
 81     const decade_count: usize = @intCast(power_max - power_min + 1);
 82     const stride = (decade_count + max_ticks - 1) / max_ticks;
 83     std.debug.assert(stride >= 1);
 84     var result = Ticks{ .values = undefined, .count = 0 };
 85     for (0..max_ticks) |index| {
 86         const power = power_min + @as(i64, @intCast(index * stride));
 87         if (power > power_max) break;
 88         result.values[result.count] = std.math.pow(f64, base, @as(f64, @floatFromInt(power)));
 89         result.count += 1;
 90     }
 91     std.debug.assert(result.count >= 2);
 92     return result;
 93 }
 94 
 95 pub fn validate(bounds: Bounds) !void {
 96     if (bounds.x_kind == .log) {
 97         if (bounds.categorical) return error.InvalidScale;
 98         if (bounds.x_min <= 0 or bounds.x_max <= 0) return error.InvalidScale;
 99     }
100     if (bounds.y_kind == .log and (bounds.y_min <= 0 or bounds.y_max <= 0)) return error.InvalidScale;
101 }
102 
103 test "linear ticks split the range evenly" {
104     const result = try ticks(0, 100, .linear, 10);
105     try std.testing.expectEqual(@as(usize, 5), result.count);
106     try std.testing.expectEqual(@as(f64, 0), result.values[0]);
107     try std.testing.expectEqual(@as(f64, 25), result.values[1]);
108     try std.testing.expectEqual(@as(f64, 100), result.values[4]);
109 }
110 
111 test "log ticks land on decades" {
112     const result = try ticks(1, 1000, .log, 10);
113     try std.testing.expectEqual(@as(usize, 4), result.count);
114     for ([_]f64{ 1, 10, 100, 1000 }, result.slice()) |expected, actual| {
115         try std.testing.expectApproxEqRel(expected, actual, 1e-9);
116     }
117 }
118 
119 test "log ticks cover fractional decades" {
120     const result = try ticks(0.001, 10, .log, 10);
121     try std.testing.expectEqual(@as(usize, 5), result.count);
122     for ([_]f64{ 0.001, 0.01, 0.1, 1, 10 }, result.slice()) |expected, actual| {
123         try std.testing.expectApproxEqRel(expected, actual, 1e-9);
124     }
125 }
126 
127 test "log ticks thin wide ranges by whole decades" {
128     const result = try ticks(1, 1e9, .log, 10);
129     try std.testing.expectEqual(@as(usize, 5), result.count);
130     for ([_]f64{ 1, 100, 1e4, 1e6, 1e8 }, result.slice()) |expected, actual| {
131         try std.testing.expectApproxEqRel(expected, actual, 1e-9);
132     }
133 }
134 
135 test "log ticks fall back to even placement inside one decade" {
136     const result = try ticks(2, 8, .log, 10);
137     try std.testing.expectEqual(@as(usize, 5), result.count);
138     try std.testing.expectApproxEqRel(@as(f64, 2), result.values[0], 1e-9);
139     try std.testing.expectApproxEqRel(@as(f64, 4), result.values[2], 1e-9);
140     try std.testing.expectApproxEqRel(@as(f64, 8), result.values[4], 1e-9);
141 }
142 
143 test "log ticks honor the configured base" {
144     const result = try ticks(1, 32, .log, 2);
145     try std.testing.expectEqual(@as(usize, 6), result.count);
146     for ([_]f64{ 1, 2, 4, 8, 16, 32 }, result.slice()) |expected, actual| {
147         try std.testing.expectApproxEqRel(expected, actual, 1e-9);
148     }
149 }