lib/zen/src/diagram/dsl.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 
  3 const root = @import("root.zig");
  4 
  5 const spec = root.spec;
  6 
  7 pub const Value = union(enum) {
  8     number: f64,
  9     text: []const u8,
 10     boolean: bool,
 11 };
 12 
 13 pub const Field = struct {
 14     name: []const u8,
 15     value: Value,
 16 };
 17 
 18 pub const Row = struct {
 19     name: []const u8,
 20     fields: []const Field,
 21 };
 22 
 23 pub const Frame = struct {
 24     width: f64 = 640,
 25     height: f64 = 360,
 26     inset: f64 = 44,
 27     axes: bool = true,
 28     title: []const u8 = "",
 29     x_label: []const u8 = "",
 30     y_label: []const u8 = "",
 31     background: []const u8 = "",
 32     y_min: ?f64 = null,
 33     y_max: ?f64 = null,
 34     x_min: ?f64 = null,
 35     x_max: ?f64 = null,
 36 };
 37 
 38 pub const Scale = struct {
 39     kind: spec.ScaleKind = .linear,
 40     min: ?f64 = null,
 41     max: ?f64 = null,
 42     base: f64 = 10,
 43     label: []const u8 = "",
 44 };
 45 
 46 pub const Scales = struct {
 47     x: Scale = .{},
 48     y: Scale = .{},
 49 };
 50 
 51 pub const Stack = struct {
 52     mark: []const u8 = "bar",
 53 };
 54 
 55 pub const Transform = union(enum) {
 56     stack: Stack,
 57 };
 58 
 59 pub const Channel = struct {
 60     data: []const u8,
 61     x: []const u8,
 62     y: []const u8,
 63     label: []const u8 = "",
 64     fill: []const u8 = "",
 65     series: []const u8 = "",
 66     text: []const u8 = "",
 67     radius: []const u8 = "",
 68 };
 69 
 70 pub const Rule = struct {
 71     x1: f64,
 72     y1: f64,
 73     x2: f64,
 74     y2: f64,
 75     stroke: []const u8 = "",
 76 };
 77 
 78 pub const Box = struct {
 79     x: f64,
 80     y: f64,
 81     width: f64,
 82     height: f64,
 83     text: []const u8 = "",
 84     fill: []const u8 = "",
 85     stroke: []const u8 = "",
 86 };
 87 
 88 pub const Edge = struct {
 89     x1: f64,
 90     y1: f64,
 91     x2: f64,
 92     y2: f64,
 93     label: []const u8 = "",
 94     stroke: []const u8 = "",
 95     arrow: bool = true,
 96 };
 97 
 98 pub const Layer = union(enum) {
 99     bar: Channel,
100     point: Channel,
101     label: Channel,
102     rule: Rule,
103     box: Box,
104     edge: Edge,
105 };
106 
107 /// Borrowed declarative diagram that can create an owned document or ASCII result.
108 pub const Figure = struct {
109     frame: Frame = .{},
110     scales: Scales = .{},
111     transforms: []const Transform = &.{},
112     data: []const Row = &.{},
113     layers: []const Layer = &.{},
114 
115     /// Copies declarative values into an owned diagram document.
116     pub fn document(self: Figure, allocator: std.mem.Allocator) !spec.Document {
117         var result = spec.Document{ .allocator = allocator };
118         errdefer result.deinit();
119         result.frame = try copyFrame(allocator, self.frame);
120         result.scales = try copyScales(allocator, self.scales);
121         for (self.transforms) |transform_value| {
122             var copied = try copyTransform(allocator, transform_value);
123             var owned = true;
124             errdefer if (owned) copied.deinit(allocator);
125             try result.transforms.append(allocator, copied);
126             owned = false;
127         }
128         for (self.data) |row_value| {
129             var copied = try copyRow(allocator, row_value);
130             var owned = true;
131             errdefer if (owned) copied.deinit(allocator);
132             try result.data.append(allocator, copied);
133             owned = false;
134         }
135         for (self.layers) |layer_value| try appendLayer(allocator, &result, layer_value);
136         return result;
137     }
138 
139     /// Returns owned ASCII output; release it with the same allocator.
140     pub fn renderAscii(
141         self: Figure,
142         allocator: std.mem.Allocator,
143         options: root.ascii.Options,
144     ) !root.ascii.Rendered {
145         var result = try self.document(allocator);
146         defer result.deinit();
147         return try root.ascii.renderOwned(allocator, &result, options);
148     }
149 };
150 
151 pub fn figure(value: Figure) Figure {
152     return value;
153 }
154 
155 pub fn row(name: []const u8, fields: []const Field) Row {
156     return .{ .name = name, .fields = fields };
157 }
158 
159 pub fn field(name: []const u8, value: Value) Field {
160     return .{ .name = name, .value = value };
161 }
162 
163 pub fn number(name: []const u8, value: f64) Field {
164     return field(name, .{ .number = value });
165 }
166 
167 pub fn text(name: []const u8, value: []const u8) Field {
168     return field(name, .{ .text = value });
169 }
170 
171 pub fn boolean(name: []const u8, value: bool) Field {
172     return field(name, .{ .boolean = value });
173 }
174 
175 pub fn bar(channel: Channel) Layer {
176     return .{ .bar = channel };
177 }
178 
179 pub fn point(channel: Channel) Layer {
180     return .{ .point = channel };
181 }
182 
183 pub fn label(channel: Channel) Layer {
184     return .{ .label = channel };
185 }
186 
187 pub fn layer(value: Layer) Layer {
188     return value;
189 }
190 
191 pub fn rule(value: Rule) Layer {
192     return .{ .rule = value };
193 }
194 
195 pub fn box(value: Box) Layer {
196     return .{ .box = value };
197 }
198 
199 pub fn edge(value: Edge) Layer {
200     return .{ .edge = value };
201 }
202 
203 pub fn stack() Transform {
204     return .{ .stack = .{} };
205 }
206 
207 fn copyFrame(allocator: std.mem.Allocator, value: Frame) !spec.Frame {
208     var result = spec.Frame{
209         .width = value.width,
210         .height = value.height,
211         .inset = value.inset,
212         .axes = value.axes,
213         .y_min = value.y_min,
214         .y_max = value.y_max,
215         .x_min = value.x_min,
216         .x_max = value.x_max,
217     };
218     errdefer result.deinit(allocator);
219     result.title = try copyString(allocator, value.title);
220     result.x_label = try copyString(allocator, value.x_label);
221     result.y_label = try copyString(allocator, value.y_label);
222     result.background = try copyString(allocator, value.background);
223     return result;
224 }
225 
226 fn copyScales(allocator: std.mem.Allocator, value: Scales) !spec.Scales {
227     var result = spec.Scales{};
228     errdefer result.deinit(allocator);
229     result.x = try copyScale(allocator, value.x);
230     result.y = try copyScale(allocator, value.y);
231     return result;
232 }
233 
234 fn copyScale(allocator: std.mem.Allocator, value: Scale) !spec.Scale {
235     var result = spec.Scale{
236         .kind = value.kind,
237         .min = value.min,
238         .max = value.max,
239         .base = value.base,
240     };
241     errdefer result.deinit(allocator);
242     result.label = try copyString(allocator, value.label);
243     return result;
244 }
245 
246 fn copyTransform(allocator: std.mem.Allocator, value: Transform) !spec.Transform {
247     return switch (value) {
248         .stack => |stack_value| .{ .stack = .{ .mark = try copyString(allocator, stack_value.mark) } },
249     };
250 }
251 
252 fn copyRow(allocator: std.mem.Allocator, value: Row) !spec.DataRow {
253     var result = spec.DataRow{ .name = try copyRequiredString(allocator, value.name) };
254     errdefer result.deinit(allocator);
255     for (value.fields) |field_value| {
256         var copied = spec.Field{ .name = &.{}, .value = .{ .number = 0 } };
257         var owned = true;
258         errdefer if (owned) copied.deinit(allocator);
259         copied.name = try copyRequiredString(allocator, field_value.name);
260         copied.value = try copyValue(allocator, field_value.value);
261         try result.fields.append(allocator, copied);
262         owned = false;
263     }
264     if (result.fields.items.len == 0) return error.InvalidData;
265     return result;
266 }
267 
268 fn copyValue(allocator: std.mem.Allocator, value: Value) !spec.DataValue {
269     return switch (value) {
270         .number => |number_value| .{ .number = number_value },
271         .text => |text_value| .{ .text = try copyString(allocator, text_value) },
272         .boolean => |boolean_value| .{ .boolean = boolean_value },
273     };
274 }
275 
276 fn appendLayer(allocator: std.mem.Allocator, document_value: *spec.Document, value: Layer) !void {
277     switch (value) {
278         .bar => |channel| try appendChannelLayer(allocator, document_value, channel, .bar),
279         .point => |channel| try appendChannelLayer(allocator, document_value, channel, .point),
280         .label => |channel| try appendChannelLayer(allocator, document_value, channel, .text),
281         .rule => |rule_value| {
282             var copied = spec.Mark{ .rule = try copyRule(allocator, rule_value) };
283             var owned = true;
284             errdefer if (owned) copied.deinit(allocator);
285             try document_value.marks.append(allocator, copied);
286             owned = false;
287         },
288         .box => |box_value| {
289             var copied = spec.Mark{ .box = try copyBox(allocator, box_value) };
290             var owned = true;
291             errdefer if (owned) copied.deinit(allocator);
292             try document_value.marks.append(allocator, copied);
293             owned = false;
294         },
295         .edge => |edge_value| {
296             var copied = spec.Mark{ .edge = try copyEdge(allocator, edge_value) };
297             var owned = true;
298             errdefer if (owned) copied.deinit(allocator);
299             try document_value.marks.append(allocator, copied);
300             owned = false;
301         },
302     }
303 }
304 
305 const ChannelLayer = enum {
306     bar,
307     point,
308     text,
309 };
310 
311 fn appendChannelLayer(allocator: std.mem.Allocator, document_value: *spec.Document, channel: Channel, kind: ChannelLayer) !void {
312     var matched = false;
313     for (document_value.data.items) |*row_value| {
314         if (!std.mem.eql(u8, row_value.name, channel.data)) continue;
315         matched = true;
316         var mark = try markFromChannel(allocator, channel, row_value, kind);
317         var owned = true;
318         errdefer if (owned) mark.deinit(allocator);
319         try document_value.marks.append(allocator, mark);
320         owned = false;
321     }
322     if (!matched) return error.UnknownData;
323 }
324 
325 fn markFromChannel(allocator: std.mem.Allocator, channel: Channel, row_value: *const spec.DataRow, kind: ChannelLayer) !spec.Mark {
326     return switch (kind) {
327         .bar => .{ .bar = try barFromChannel(allocator, channel, row_value) },
328         .point => .{ .point = try pointFromChannel(allocator, channel, row_value) },
329         .text => .{ .text = try textFromChannel(allocator, channel, row_value) },
330     };
331 }
332 
333 fn barFromChannel(allocator: std.mem.Allocator, channel: Channel, row_value: *const spec.DataRow) !spec.Bar {
334     var result = spec.Bar{ .x = &.{}, .y = 0 };
335     errdefer result.deinit(allocator);
336     result.x = try requiredText(allocator, row_value, channel.x);
337     result.y = try requiredNumber(row_value, channel.y);
338     result.series = try optionalText(allocator, row_value, channel.series);
339     result.label = try optionalText(allocator, row_value, channel.label);
340     result.fill = try optionalText(allocator, row_value, channel.fill);
341     return result;
342 }
343 
344 fn pointFromChannel(allocator: std.mem.Allocator, channel: Channel, row_value: *const spec.DataRow) !spec.Point {
345     var result = spec.Point{ .x = .{ .number = 0 }, .y = 0 };
346     errdefer result.deinit(allocator);
347     result.x = try requiredX(allocator, row_value, channel.x);
348     result.y = try requiredNumber(row_value, channel.y);
349     result.label = try optionalText(allocator, row_value, channel.label);
350     result.fill = try optionalText(allocator, row_value, channel.fill);
351     result.radius = if (channel.radius.len == 0) 4 else try requiredNumber(row_value, channel.radius);
352     return result;
353 }
354 
355 fn textFromChannel(allocator: std.mem.Allocator, channel: Channel, row_value: *const spec.DataRow) !spec.Text {
356     if (channel.text.len == 0) return error.MissingField;
357     var result = spec.Text{ .x = .{ .number = 0 }, .y = 0, .text = &.{} };
358     errdefer result.deinit(allocator);
359     result.x = try requiredX(allocator, row_value, channel.x);
360     result.y = try requiredNumber(row_value, channel.y);
361     result.text = try requiredText(allocator, row_value, channel.text);
362     result.fill = try optionalText(allocator, row_value, channel.fill);
363     return result;
364 }
365 
366 fn copyRule(allocator: std.mem.Allocator, value: Rule) !spec.Rule {
367     var result = spec.Rule{
368         .x1 = value.x1,
369         .y1 = value.y1,
370         .x2 = value.x2,
371         .y2 = value.y2,
372     };
373     errdefer result.deinit(allocator);
374     result.stroke = try copyString(allocator, value.stroke);
375     return result;
376 }
377 
378 fn copyBox(allocator: std.mem.Allocator, value: Box) !spec.Box {
379     if (value.width <= 0 or value.height <= 0) return error.InvalidField;
380     var result = spec.Box{
381         .x = value.x,
382         .y = value.y,
383         .width = value.width,
384         .height = value.height,
385     };
386     errdefer result.deinit(allocator);
387     result.text = try copyString(allocator, value.text);
388     result.fill = try copyString(allocator, value.fill);
389     result.stroke = try copyString(allocator, value.stroke);
390     return result;
391 }
392 
393 fn copyEdge(allocator: std.mem.Allocator, value: Edge) !spec.Edge {
394     var result = spec.Edge{
395         .x1 = value.x1,
396         .y1 = value.y1,
397         .x2 = value.x2,
398         .y2 = value.y2,
399         .arrow = value.arrow,
400     };
401     errdefer result.deinit(allocator);
402     result.label = try copyString(allocator, value.label);
403     result.stroke = try copyString(allocator, value.stroke);
404     return result;
405 }
406 
407 fn requiredValue(row_value: *const spec.DataRow, name: []const u8) !spec.DataValue {
408     if (name.len == 0) return error.MissingField;
409     return row_value.lookup(name) orelse error.MissingField;
410 }
411 
412 fn requiredNumber(row_value: *const spec.DataRow, name: []const u8) !f64 {
413     return numberValue(try requiredValue(row_value, name));
414 }
415 
416 fn requiredText(allocator: std.mem.Allocator, row_value: *const spec.DataRow, name: []const u8) ![]u8 {
417     return textValue(allocator, try requiredValue(row_value, name));
418 }
419 
420 fn requiredX(allocator: std.mem.Allocator, row_value: *const spec.DataRow, name: []const u8) !spec.XValue {
421     return xValue(allocator, try requiredValue(row_value, name));
422 }
423 
424 fn optionalText(allocator: std.mem.Allocator, row_value: *const spec.DataRow, name: []const u8) ![]u8 {
425     if (name.len == 0) return &.{};
426     return requiredText(allocator, row_value, name);
427 }
428 
429 fn numberValue(value: spec.DataValue) !f64 {
430     return switch (value) {
431         .number => |number_value| number_value,
432         .text => |text_value| std.fmt.parseFloat(f64, text_value) catch error.InvalidField,
433         .boolean => error.InvalidField,
434     };
435 }
436 
437 fn textValue(allocator: std.mem.Allocator, value: spec.DataValue) ![]u8 {
438     return switch (value) {
439         .number => |number_value| try std.fmt.allocPrint(allocator, "{d}", .{number_value}),
440         .text => |text_value| try copyString(allocator, text_value),
441         .boolean => |boolean_value| try copyString(allocator, if (boolean_value) "true" else "false"),
442     };
443 }
444 
445 fn xValue(allocator: std.mem.Allocator, value: spec.DataValue) !spec.XValue {
446     return switch (value) {
447         .number => |number_value| .{ .number = number_value },
448         .text => |text_value| .{ .text = try copyString(allocator, text_value) },
449         .boolean => |boolean_value| .{ .text = try copyString(allocator, if (boolean_value) "true" else "false") },
450     };
451 }
452 
453 fn copyRequiredString(allocator: std.mem.Allocator, value: []const u8) ![]u8 {
454     if (value.len == 0) return error.MissingField;
455     return copyString(allocator, value);
456 }
457 
458 fn copyString(allocator: std.mem.Allocator, value: []const u8) ![]u8 {
459     if (value.len == 0) return &.{};
460     return try allocator.dupe(u8, value);
461 }
462 
463 test "diagram DSL renders data-driven bars as ASCII" {
464     const plot = figure(.{
465         .frame = .{ .title = "Coin", .y_min = 0, .y_max = 1, .x_label = "outcome", .y_label = "p" },
466         .data = &.{
467             row("coin", &.{ text("outcome", "heads"), number("probability", 0.62), text("label", "heads") }),
468             row("coin", &.{ text("outcome", "tails"), number("probability", 0.38), text("label", "tails") }),
469         },
470         .layers = &.{
471             bar(.{ .data = "coin", .x = "outcome", .y = "probability", .label = "label" }),
472         },
473     });
474     var rendered = try plot.renderAscii(std.testing.allocator, .{ .width = 40, .height = 12 });
475     defer rendered.deinit(std.testing.allocator);
476     try std.testing.expect(std.mem.indexOf(u8, rendered.output, "Coin") != null);
477     try std.testing.expect(std.mem.indexOf(u8, rendered.output, "heads") != null);
478     try std.testing.expect(std.mem.indexOf(u8, rendered.output, "#") != null);
479 }