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 }