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 }