From 7e252781fc9289c249aa3f89f7671a14a1fbb167 Mon Sep 17 00:00:00 2001 From: Phil Scott Date: Sat, 11 Jul 2026 12:33:33 -0400 Subject: [PATCH 1/4] feat(nodes): card items/body content + diamond & parallelogram shapes Two self-contained additions to the node/card vocabulary, both emitted only when present so every existing document stays byte-identical. Card content blocks: - `items:` (bulleted list) and `body:` (wrapped paragraph) on cards, defaulted in the model (Schema/Validate/ModelJson) and mirrored in the authoring NodeBuilder (Item/Items/Body). - StyleGeometry gains ItemLine/ItemGap/BodyLine (optional-with-default, mirroring the class-compartment pitch); CardSizer measures the new blocks. New node shapes (flowchart groundwork): - Diamond & Parallelogram NodeShapes: Artwork polygon paths + sketch wobble + brutalist offset-polygon shadow; SvgRenderer emit with inscribed-box text wrap. - Router support: diamonds are point-anchored to their vertices (never spread or straightened off); parallelogram left/right anchors nudge onto the slanted face by skew/2. Zero-nudge / non-diamond paths are byte-identical. Tests: CardItemsBodySizingTests, DiamondParallelogramTests. Full suite green (434). --- src/Beck/Authoring/Builders.cs | 21 +++ src/Beck/BeckStyle.cs | 18 ++ src/Beck/Model/ClassBuilder.cs | 1 + src/Beck/Model/ModelJson.cs | 12 ++ src/Beck/Model/Schema.cs | 4 + src/Beck/Model/StateBuilder.cs | 2 + src/Beck/Model/Tokens.cs | 6 +- src/Beck/Model/Validate.cs | 7 +- src/Beck/Route/EdgePainter.cs | 23 ++- src/Beck/Route/OrthogonalRouter.cs | 28 ++- src/Beck/Svg/Artwork.cs | 121 +++++++++++++ src/Beck/Svg/SvgRenderer.cs | 87 ++++++++++ src/Beck/Text/CardSizer.cs | 115 ++++++++++++- tests/Beck.Tests/CardItemsBodySizingTests.cs | 159 +++++++++++++++++ tests/Beck.Tests/DiamondParallelogramTests.cs | 160 ++++++++++++++++++ 15 files changed, 756 insertions(+), 8 deletions(-) create mode 100644 tests/Beck.Tests/CardItemsBodySizingTests.cs create mode 100644 tests/Beck.Tests/DiamondParallelogramTests.cs diff --git a/src/Beck/Authoring/Builders.cs b/src/Beck/Authoring/Builders.cs index 83d00aa..2e3619a 100644 --- a/src/Beck/Authoring/Builders.cs +++ b/src/Beck/Authoring/Builders.cs @@ -12,6 +12,8 @@ public sealed class NodeBuilder private readonly string _id; private string? _title; private string? _subtitle; + private readonly List _items = new(); + private string? _body; private string? _icon; private string? _status; private string? _accent; @@ -37,6 +39,15 @@ public sealed class NodeBuilder /// Set the muted subtitle line. public NodeBuilder Subtitle(string subtitle) { _subtitle = subtitle; return this; } + /// Add one bullet to the card's item list. + public NodeBuilder Item(string item) { _items.Add(item); return this; } + + /// Add several bullets to the card's item list at once. + public NodeBuilder Items(params string[] items) { _items.AddRange(items); return this; } + + /// Set the wrapped body paragraph rendered under the items. + public NodeBuilder Body(string body) { _body = body; return this; } + /// Set a named icon key or raw inline <svg> markup. public NodeBuilder Icon(string icon) { _icon = icon; return this; } @@ -89,6 +100,16 @@ internal string ToFlow() pairs.Add(("subtitle", YamlWriter.Scalar(_subtitle))); } + if (_items.Count > 0) + { + pairs.Add(("items", YamlWriter.FlowSeq(_items.Select(YamlWriter.Scalar)))); + } + + if (_body != null) + { + pairs.Add(("body", YamlWriter.Scalar(_body))); + } + if (_kind is { } k) { pairs.Add(("kind", Tokens.Of(k))); diff --git a/src/Beck/BeckStyle.cs b/src/Beck/BeckStyle.cs index 532a72c..23dc906 100644 --- a/src/Beck/BeckStyle.cs +++ b/src/Beck/BeckStyle.cs @@ -1243,6 +1243,24 @@ public sealed record StyleGeometry /// Class-member line height (px). public required double MemberLine { get; init; } + // ---- card content blocks (items / body) ---- + /// + /// Line height (px) of a bulleted card items: row. Optional-with-default so no existing + /// style has to set it; the default mirrors (1.45 · 11.52) so a + /// card's bullet list stacks at the same pitch as a class compartment's members — byte-identical + /// for any card without items: (the block is never sized when the list is empty). + /// + public double ItemLine { get; init; } = 1.45 * 11.52; + + /// Vertical gap (px) between consecutive card items: rows. Mirrors + /// so a bullet list matches class-compartment spacing. + public double ItemGap { get; init; } = 2; + + /// Line height (px) of a wrapped card body: paragraph line. Optional-with-default; + /// the default mirrors (1.35 · 12) since the body renders in the + /// card-subtitle font. Never sized when body: is null — byte-identical for cards without it. + public double BodyLine { get; init; } = 1.35 * 12; + // ---- start/end pseudo-state ---- /// Side length of the start/end pseudo-state marker box. public required double StartEndSize { get; init; } diff --git a/src/Beck/Model/ClassBuilder.cs b/src/Beck/Model/ClassBuilder.cs index 7da7419..baec5e3 100644 --- a/src/Beck/Model/ClassBuilder.cs +++ b/src/Beck/Model/ClassBuilder.cs @@ -33,6 +33,7 @@ private static NodeModel ClassNode(IReadOnlyDictionary c) Group = OptString(c.GetValueOrDefault("group")), Shape = NodeShape.Class, Stereotype = OptString(c.GetValueOrDefault("stereotype")), + Items = [], Fields = StringList(c.GetValueOrDefault("fields"), $"class \"{id}\" fields"), Methods = StringList(c.GetValueOrDefault("methods"), $"class \"{id}\" methods"), }; diff --git a/src/Beck/Model/ModelJson.cs b/src/Beck/Model/ModelJson.cs index 3f15d04..3df039d 100644 --- a/src/Beck/Model/ModelJson.cs +++ b/src/Beck/Model/ModelJson.cs @@ -76,6 +76,18 @@ public static string Canonical(DiagramModel m) o["subtitle"] = n.Subtitle; } + // New card content blocks. Emitted only when present so every existing model-parity + // golden (which predates these fields) stays byte-identical. + if (n.Items.Count > 0) + { + o["items"] = n.Items.ToList(); + } + + if (n.Body != null) + { + o["body"] = n.Body; + } + if (n.Icon != null) { o["icon"] = n.Icon; diff --git a/src/Beck/Model/Schema.cs b/src/Beck/Model/Schema.cs index 266a402..5a948af 100644 --- a/src/Beck/Model/Schema.cs +++ b/src/Beck/Model/Schema.cs @@ -34,6 +34,10 @@ internal sealed record NodeModel public required string Id { get; init; } public required string Title { get; init; } public string? Subtitle { get; init; } + /// Bulleted list rendered under the title/subtitle on a card (empty when unset). + public required IReadOnlyList Items { get; init; } + /// Wrapped paragraph rendered under the items on a card, or null when unset. + public string? Body { get; init; } /// Named icon key OR raw inline <svg> markup. Resolved at render time. public string? Icon { get; init; } public required NodeKind Kind { get; init; } diff --git a/src/Beck/Model/StateBuilder.cs b/src/Beck/Model/StateBuilder.cs index 3466c40..99cc8ac 100644 --- a/src/Beck/Model/StateBuilder.cs +++ b/src/Beck/Model/StateBuilder.cs @@ -37,6 +37,7 @@ private static NodeModel StatePill(IReadOnlyDictionary s) Rank = OptNumber(s.GetValueOrDefault("rank"), $"state \"{id}\" rank"), Order = OptNumber(s.GetValueOrDefault("order"), $"state \"{id}\" order"), Shape = NodeShape.Pill, + Items = [], Fields = [], Methods = [], }; @@ -50,6 +51,7 @@ private static NodeModel StatePill(IReadOnlyDictionary s) Variant = NodeVariant.Solid, Accent = "var(--beck-text)", Shape = id == StartId ? NodeShape.Start : NodeShape.End, + Items = [], Fields = [], Methods = [], }; diff --git a/src/Beck/Model/Tokens.cs b/src/Beck/Model/Tokens.cs index 8a71d00..47d1bba 100644 --- a/src/Beck/Model/Tokens.cs +++ b/src/Beck/Model/Tokens.cs @@ -19,7 +19,7 @@ internal enum NodeKind { Service, Db, Queue, Cache, Gateway, External, User, Gho internal enum NodeVariant { Solid, Subtle, Ghost } /// Structural form of a node. -internal enum NodeShape { Card, Pill, Start, End, Class } +internal enum NodeShape { Card, Pill, Start, End, Class, Diamond, Parallelogram } internal enum EdgeStyle { Solid, Dashed } @@ -114,7 +114,9 @@ internal static class Tokens (Model.NodeShape.Pill, "pill"), (Model.NodeShape.Start, "start"), (Model.NodeShape.End, "end"), - (Model.NodeShape.Class, "class")); + (Model.NodeShape.Class, "class"), + (Model.NodeShape.Diamond, "diamond"), + (Model.NodeShape.Parallelogram, "parallelogram")); public static readonly TokenMap EdgeStyle = new( (Model.EdgeStyle.Solid, "solid"), diff --git a/src/Beck/Model/Validate.cs b/src/Beck/Model/Validate.cs index bbfc68a..a434906 100644 --- a/src/Beck/Model/Validate.cs +++ b/src/Beck/Model/Validate.cs @@ -94,6 +94,8 @@ public static NodeModel BuildNode(IReadOnlyDictionary n) Id = id, Title = OptString(n.GetValueOrDefault("title")) ?? id, Subtitle = OptString(n.GetValueOrDefault("subtitle")), + Items = StringList(n.GetValueOrDefault("items"), $"node \"{id}\" items"), + Body = OptString(n.GetValueOrDefault("body")), Icon = rawIcon != null && Icons.IsKnownIcon(rawIcon) ? rawIcon : kd.Icon, Kind = kind, Variant = OneOf(n.GetValueOrDefault("variant"), Tokens.NodeVariant, $"node \"{id}\" variant", kd.Variant), @@ -107,7 +109,10 @@ public static NodeModel BuildNode(IReadOnlyDictionary n) Rank = OptNumber(n.GetValueOrDefault("rank"), $"node \"{id}\" rank"), Order = OptNumber(n.GetValueOrDefault("order"), $"node \"{id}\" order"), Group = OptString(n.GetValueOrDefault("group")), - Shape = NodeShape.Card, + // Architecture nodes default to Card; `shape: diamond`/`parallelogram` (flowchart + // groundwork) opt into the point/slanted primitives. Absent `shape:` → Card, so every + // existing document is byte-identical. + Shape = OneOf(n.GetValueOrDefault("shape"), Tokens.NodeShape, $"node \"{id}\" shape", NodeShape.Card), Fields = [], Methods = [], }; diff --git a/src/Beck/Route/EdgePainter.cs b/src/Beck/Route/EdgePainter.cs index db1102c..3802357 100644 --- a/src/Beck/Route/EdgePainter.cs +++ b/src/Beck/Route/EdgePainter.cs @@ -102,9 +102,23 @@ void Walk(string gid) // Terminal dots (state [*] start/end, 16×16) are points, not faces — spreading // several edges across one just bends each into a tiny jog. Anchor them all at // centre so a lone incoming edge can run straight into the dot. + // + // Diamonds are point-anchored too: each bbox face-midpoint coincides EXACTLY with a diamond + // vertex, so an anchor must stay pinned to it — never spread (suppressed here), and never slid + // off by the router's straighten cheat (marked fanned below). Only diamonds get the fanned + // override so state start/end routing stays byte-identical. + var diamondNodes = model.Nodes + .Where(n => n.Shape is NodeShape.Diamond) + .Select(n => n.Id).ToHashSet(); var pointNodes = model.Nodes .Where(n => n.Shape is NodeShape.Start or NodeShape.End) .Select(n => n.Id).ToHashSet(); + pointNodes.UnionWith(diamondNodes); + // Parallelogram left/right anchors sit on a slanted face: the router nudges them inward by + // skew/2 in x. Top/bottom faces are unslanted (no nudge). Keyed by node id → skew. + var paraSkew = model.Nodes + .Where(n => n.Shape is NodeShape.Parallelogram && layout.Nodes.ContainsKey(n.Id)) + .ToDictionary(n => n.Id, n => Svg.Artwork.ParallelogramSkew(layout.Nodes[n.Id].H) / 2); var shifts = AnchorShifts(model.Edges, prep, pointNodes); var outEdges = new List(); @@ -118,10 +132,17 @@ void Walk(string gid) var edge = model.Edges[i]; var (from, to) = shifts[i]; + // A diamond end is pinned to its vertex: mark it fanned (immovable) so straighten leaves it. + var fromFanned = from.Fanned || diamondNodes.Contains(edge.From); + var toFanned = to.Fanned || diamondNodes.Contains(edge.To); + // A parallelogram end's Left/Right anchor slides inward by skew/2 onto the slant (0 otherwise; + // NudgePerp no-ops Top/Bottom faces). + var fromNudge = paraSkew.TryGetValue(edge.From, out var fn) ? fn : 0; + var toNudge = paraSkew.TryGetValue(edge.To, out var tn) ? tn : 0; var routed = OrthogonalRouter.RouteEdge(new RouteRequest( p.From, p.To, p.FromSide, p.ToSide, edge.Curve, p.Obstacles, radius, primaryHorizontal, new Size(layout.Width, layout.Height), from.Shift, to.Shift, - from.Fanned, to.Fanned, from.Lane, to.Lane)); + fromFanned, toFanned, from.Lane, to.Lane, fromNudge, toNudge)); outEdges.Add(new RoutedEdge(edge, routed.D, routed.Points)); } return outEdges; diff --git a/src/Beck/Route/OrthogonalRouter.cs b/src/Beck/Route/OrthogonalRouter.cs index 6800be7..990f9cc 100644 --- a/src/Beck/Route/OrthogonalRouter.cs +++ b/src/Beck/Route/OrthogonalRouter.cs @@ -34,7 +34,11 @@ internal sealed record RouteRequest( // evenly spread by AnchorShifts, and sliding any one of them — including the middle edge, // whose shift is 0 — breaks that spacing. Not inferable from the shift alone. bool FromFanned = false, bool ToFanned = false, - Lane FromLane = default, Lane ToLane = default); + Lane FromLane = default, Lane ToLane = default, + // Perpendicular inward nudge for a slanted face (parallelogram): a Left/Right anchor slides + // inward by skew/2 in x so it lands on the slanted face rather than the bbox edge. 0 for every + // straight-faced shape — byte-identical. + double FromNudge = 0, double ToNudge = 0); internal sealed record RoutedPath(string D, IReadOnlyList Points); @@ -355,6 +359,24 @@ private static Point ShiftAnchor(Point p, Side side, double off) return IsVertical(side) ? new Point(p.X + off, p.Y) : new Point(p.X, p.Y + off); } + /// Slide a Left/Right anchor inward along x by so it lands on a + /// slanted (parallelogram) face; Top/Bottom faces are unslanted, so they are left alone. Left moves + /// right (+x), Right moves left (−x). A zero nudge is a no-op (every straight-faced shape). + private static Point NudgePerp(Point p, Side side, double nudge) + { + if (nudge == 0) + { + return p; + } + + return side switch + { + Side.Left => new Point(p.X + nudge, p.Y), + Side.Right => new Point(p.X - nudge, p.Y), + _ => p, + }; + } + private static List SameFaceLoop(Point a, Point b, Side side, IReadOnlyList obstacles, Size? bounds) { if (IsVertical(side)) @@ -566,8 +588,8 @@ public static RoutedPath RouteEdge(RouteRequest req) var auto = AutoSides(req.From, req.To, req.PrimaryHorizontal); var fromSide = req.FromSide ?? auto.FromSide; var toSide = req.ToSide ?? auto.ToSide; - var a = ShiftAnchor(Anchor(req.From, fromSide), fromSide, req.FromShift); - var b = ShiftAnchor(Anchor(req.To, toSide), toSide, req.ToShift); + var a = NudgePerp(ShiftAnchor(Anchor(req.From, fromSide), fromSide, req.FromShift), fromSide, req.FromNudge); + var b = NudgePerp(ShiftAnchor(Anchor(req.To, toSide), toSide, req.ToShift), toSide, req.ToNudge); if (req.Curve == EdgeCurve.Straight) { diff --git a/src/Beck/Svg/Artwork.cs b/src/Beck/Svg/Artwork.cs index ebbeb60..55f42f1 100644 --- a/src/Beck/Svg/Artwork.cs +++ b/src/Beck/Svg/Artwork.cs @@ -121,6 +121,96 @@ private static string Behind(BeckStyle style, bool want, double x, double y, dou return ""; } + /// + /// A diamond node (flowchart decision groundwork): a clean four-vertex closed <path> + /// through the bbox face midpoints under , or a wobbly variant + /// under . The four vertices sit exactly on the bbox face + /// midpoints, so the router's default face anchors coincide with them. Depth () + /// reuses the brutalist hard-offset sticker shadow, offset as a polygon rather than a rect so it + /// stays coherent with the shape; extruded/circuit depth is rect-edge specific and omitted here. + /// + public static string Diamond(BeckStyle style, string cls, double x, double y, double w, double h, + string seed, string? styleAttr = null, bool shadow = false) + { + var sa = styleAttr != null ? $" style=\"{styleAttr}\"" : ""; + double cx = x + w / 2, cy = y + h / 2; + var pts = new (double X, double Y)[] { (cx, y), (x + w, cy), (cx, y + h), (x, cy) }; + var sh = BehindPoly(style, shadow, pts); + if (style.Artwork != StyleArtwork.Sketch) + { + return $"{sh}"; + } + + return $"{sh}"; + } + + /// + /// A parallelogram node (flowchart I/O groundwork): a four-corner closed <path> with a + /// fixed horizontal skew of min(12, h·0.4), the top edge shifted right. Plain draws a clean + /// polygon; sketch wobbles it. The top/bottom edges lie on the bbox faces (so those anchors need no + /// adjustment); the left/right faces are slanted (the router shifts those anchors by skew/2). + /// Depth reuses the brutalist offset-polygon shadow as for . + /// + public static string Parallelogram(BeckStyle style, string cls, double x, double y, double w, double h, + string seed, string? styleAttr = null, bool shadow = false) + { + var sa = styleAttr != null ? $" style=\"{styleAttr}\"" : ""; + var skew = ParallelogramSkew(h); + var pts = new (double X, double Y)[] + { + (x + skew, y), (x + w, y), (x + w - skew, y + h), (x, y + h), + }; + var sh = BehindPoly(style, shadow, pts); + if (style.Artwork != StyleArtwork.Sketch) + { + return $"{sh}"; + } + + return $"{sh}"; + } + + /// The parallelogram's fixed horizontal skew — the amount the top edge shifts right (and the + /// bottom edge left). The router adds skew/2 to left/right anchors so they land on the slant. + public static double ParallelogramSkew(double h) => Math.Min(12, h * 0.4); + + /// A closed straight polygon path (M…L…L…Z) through . + private static string Poly(IReadOnlyList<(double X, double Y)> pts) + { + var sb = new StringBuilder(); + sb.Append('M').Append(F(pts[0])); + for (var i = 1; i < pts.Count; i++) + { + sb.Append('L').Append(F(pts[i])); + } + + sb.Append('Z'); + return sb.ToString(); + } + + /// + /// The behind-the-node depth treatment for arbitrary polygon shapes (diamond/parallelogram). Only + /// 's hard offset "sticker" shadow generalizes cleanly — the same + /// solid, blur-free polygon offset down-right by px, filled + /// through --beck-shadow. Extruded's two lit faces and circuit's edge pins are rect-edge + /// specific, so they are omitted for these shapes (byte-identical for every other style / zero offset). + /// + private static string BehindPoly(BeckStyle style, bool want, IReadOnlyList<(double X, double Y)> pts) + { + if (!want || style.Artwork != StyleArtwork.Brutalist) + { + return ""; + } + + var o = style.Geometry.ShadowOffset; + if (o <= 0) + { + return ""; + } + + var shifted = pts.Select(p => (p.X + o, p.Y + o)).ToList(); + return $""; + } + /// /// A pseudo-state circle (start/end nodes, the end dot): a true <circle> under /// , or a wobbly closed blob <path> with the same @@ -364,5 +454,36 @@ private static string WobbleCircle(double cx, double cy, double r, string seed) return sb.ToString(); } + /// + /// A closed hand-drawn polygon: each vertex jittered a touch off true, each straight edge bowed + /// through a jittered midpoint (the true midpoint as the quadratic control), so the shape stays + /// closed and gently hand-drawn. Amplitudes track the shape's long dimension exactly as + /// does, and every value comes from the deterministic + /// stream — no RNG, no time. Used by /. + /// + private static string WobblePolygon(IReadOnlyList<(double X, double Y)> pts, double longDim, string seed) + { + var rng = new Rng(seed); + var s = Math.Clamp((longDim - 90) / 220.0, 0, 1); + var a = 2.2 + 1.4 * s; // vertex jitter amplitude + var b = 1.8 + 1.1 * s; // edge-bow amplitude + double J() => (rng.Next() - 0.5) * 2 * a; + double Bow() => (rng.Next() - 0.5) * 2 * b; + + var v = pts.Select(p => (X: p.X + J(), Y: p.Y + J())).ToList(); + var sb = new StringBuilder(); + sb.Append('M').Append(F(v[0])); + for (var i = 0; i < v.Count; i++) + { + var p = v[i]; + var q = v[(i + 1) % v.Count]; + (double X, double Y) mid = ((p.X + q.X) / 2 + Bow(), (p.Y + q.Y) / 2 + Bow()); + sb.Append('Q').Append(F(mid)).Append(' ').Append(F(q)); + } + + sb.Append('Z'); + return sb.ToString(); + } + private static string F((double X, double Y) pt) => N(pt.X) + " " + N(pt.Y); } \ No newline at end of file diff --git a/src/Beck/Svg/SvgRenderer.cs b/src/Beck/Svg/SvgRenderer.cs index 4a2a381..f3cab59 100644 --- a/src/Beck/Svg/SvgRenderer.cs +++ b/src/Beck/Svg/SvgRenderer.cs @@ -583,6 +583,8 @@ private static string Node(NodeModel node, Rect rect, ITextMeasurer m, string ha .Append(Artwork.Circle(style, "beck-end-dot", w / 2, h / 2, 3.5, hash + ":" + node.Id + ":dot")); break; case NodeShape.Class: EmitClass(sb, node, w, h, m, hash, style, guard); break; + case NodeShape.Diamond: EmitDiamond(sb, node, w, h, m, hash, style, guard); break; + case NodeShape.Parallelogram: EmitParallelogram(sb, node, w, h, m, hash, style, guard); break; default: if (node.Variant == NodeVariant.Ghost || node.Kind == NodeKind.Ghost) { @@ -616,6 +618,8 @@ private static NodeBox CardBox(NodeModel node, Rect r, StyleGeometry geo) NodeShape.Pill => r.H / 2, NodeShape.Class => geo.ClassRadius, NodeShape.Start or NodeShape.End => Math.Min(r.W, r.H) / 2, + // Diamond/parallelogram have sharp corners; the fx overlay box is a plain rect, so 0. + NodeShape.Diamond or NodeShape.Parallelogram => 0, _ => geo.CardRadius, }; var inset = geo.NodeBorderInset; @@ -711,6 +715,59 @@ private static void EmitClass(StringBuilder sb, NodeModel node, double w, double sb.Append(""); } + /// A diamond node (flowchart decision groundwork): the diamond outline plus a centered + /// title/subtitle stack wrapped into the inscribed rectangle (), + /// so the drawn wrap matches the box the sizer measured. Icons are skipped (the point geometry leaves + /// no clean icon gutter); every measured run keeps its textLength guard. + private static void EmitDiamond(StringBuilder sb, NodeModel node, double w, double h, ITextMeasurer m, string hash, BeckStyle style, bool guard) + { + var geo = style.Geometry; + var bi = geo.NodeBorderInset; + sb.Append(Artwork.Diamond(style, "beck-node beck-node--diamond", bi, bi, w - 2 * bi, h - 2 * bi, hash + ":" + node.Id, shadow: true)); + EmitCenteredStack(sb, node, w, h, m, style, guard, + CardSizer.DiamondTextAvail(node, m, geo, style.Typography.Roles, style.Typography.TitlePrefix, style.Typography.TitleSuffix)); + } + + /// A parallelogram node (flowchart I/O groundwork): the skewed outline plus a centered + /// title/subtitle stack wrapped into the card text column (). + /// The parallelogram is centrally symmetric, so centering at w/2 clears both slants. + private static void EmitParallelogram(StringBuilder sb, NodeModel node, double w, double h, ITextMeasurer m, string hash, BeckStyle style, bool guard) + { + var geo = style.Geometry; + var bi = geo.NodeBorderInset; + sb.Append(Artwork.Parallelogram(style, "beck-node beck-node--parallelogram", bi, bi, w - 2 * bi, h - 2 * bi, hash + ":" + node.Id, shadow: true)); + EmitCenteredStack(sb, node, w, h, m, style, guard, + CardSizer.ParallelogramTextAvail(node, m, geo, style.Typography.Roles, style.Typography.TitlePrefix, style.Typography.TitleSuffix)); + } + + /// The shared centered title/subtitle stack for the diamond/parallelogram shapes: wrap both + /// runs at (the SAME width the sizer used), then center the whole block in the + /// bbox and each line at w/2. Card-role typography and line metrics; every run carries its guard. + private static void EmitCenteredStack(StringBuilder sb, NodeModel node, double w, double h, ITextMeasurer m, BeckStyle style, bool guard, double avail) + { + var geo = style.Geometry; + double titleLine = geo.CardTitleLine, subLine = geo.CardSubLine, textGap = geo.TextGap; + var titleLines = CardSizer.WrapText(m, style.Typography.DecorateTitle(node.Title), FontRole.CardTitle, avail, style.Typography.Roles); + var subLines = node.Subtitle != null ? CardSizer.WrapText(m, node.Subtitle, FontRole.CardSubtitle, avail, style.Typography.Roles) : null; + var textColH = titleLines.Count * titleLine + (subLines != null ? textGap + subLines.Count * subLine : 0); + var stackY = h / 2 - textColH / 2; + foreach (var line in titleLines) + { + CenterLine(sb, line, "beck-node-title", w / 2, stackY + titleLine / 2, m, style, FontRole.CardTitle, guard); + stackY += titleLine; + } + + if (subLines != null) + { + stackY += textGap; + foreach (var line in subLines) + { + CenterLine(sb, line, "beck-node-subtitle", w / 2, stackY + subLine / 2, m, style, FontRole.CardSubtitle, guard); + stackY += subLine; + } + } + } + /// A class compartment separator: the straight <line> (classic — byte-identical), /// or, when the style sets (sketch), a subtle wobbly /// <path> carrying the same class with its two endpoints preserved and jitter hash-seeded. @@ -767,6 +824,7 @@ private static void EmitCard(StringBuilder sb, NodeModel node, double w, double var statusChipH = geo.StatusChipH; double titleLine = geo.CardTitleLine, subLine = geo.CardSubLine, textGap = geo.TextGap; + double itemLine = geo.ItemLine, itemGap = geo.ItemGap, bodyLine = geo.BodyLine; // The same flow-status texts CardSizer sized the box for (row height + widest pill), so // wrapping and vertical centering here match the reserved space exactly. var stateTexts = NonEmptyStatusTexts(states); @@ -776,8 +834,13 @@ private static void EmitCard(StringBuilder sb, NodeModel node, double w, double var avail = CardSizer.CardTextAvail(node, m, geo, style.Typography.Roles, style.Typography.TitlePrefix, style.Typography.TitleSuffix, stateTexts); var titleLines = CardSizer.WrapText(m, style.Typography.DecorateTitle(node.Title), FontRole.CardTitle, avail, style.Typography.Roles); var subLines = node.Subtitle != null ? CardSizer.WrapText(m, node.Subtitle, FontRole.CardSubtitle, avail, style.Typography.Roles) : null; + // Items are single rows (never wrapped); the body wraps into CardTextAvail exactly as CardSizer sized it. + var hasItems = node.Items.Count > 0; + var bodyLines = node.Body != null ? CardSizer.WrapText(m, node.Body, FontRole.CardSubtitle, avail, style.Typography.Roles) : null; var textColH = titleLines.Count * titleLine + (subLines != null ? textGap + subLines.Count * subLine : 0) + + (hasItems ? textGap + node.Items.Count * itemLine + (node.Items.Count - 1) * itemGap : 0) + + (bodyLines != null ? textGap + bodyLines.Count * bodyLine : 0) + (node.Status != null || hasStates ? textGap + geo.StatusMt + statusChipH : 0); var top = h / 2 - textColH / 2; var stackY = top; @@ -795,6 +858,30 @@ private static void EmitCard(StringBuilder sb, NodeModel node, double w, double stackY += subLine; } } + if (hasItems) + { + stackY += textGap; + for (var i = 0; i < node.Items.Count; i++) + { + if (i > 0) + { + stackY += itemGap; + } + + // Bullet baked into the measured+drawn run (CardSizer.ItemBullet) so the textLength guard matches. + Line(sb, CardSizer.ItemBullet + node.Items[i], "beck-node-subtitle", textX, stackY + itemLine / 2, m, style, FontRole.CardSubtitle, guard); + stackY += itemLine; + } + } + if (bodyLines != null) + { + stackY += textGap; + foreach (var line in bodyLines) + { + Line(sb, line, "beck-node-subtitle", textX, stackY + bodyLine / 2, m, style, FontRole.CardSubtitle, guard); + stackY += bodyLine; + } + } if (node.Status is { } || states != null) { var sy = stackY + textGap + geo.StatusMt; diff --git a/src/Beck/Text/CardSizer.cs b/src/Beck/Text/CardSizer.cs index 7fc20ed..0787c3d 100644 --- a/src/Beck/Text/CardSizer.cs +++ b/src/Beck/Text/CardSizer.cs @@ -21,6 +21,11 @@ namespace Beck.Text; /// internal static class CardSizer { + /// The bullet prefix baked into each card items: row before measuring, so the + /// measured advance matches the run draws (measured == drawn). + internal const string ItemBullet = "• "; + + /// Measure a node's card to its rounded border-box size. The box-model constants come from /// and the per-role typography from (both /// default to 's). Passing the style's @@ -39,6 +44,8 @@ public static Size Measure(NodeModel node, ITextMeasurer m, StyleGeometry? geome NodeShape.Pill => Pill(node, m, g, r, title), NodeShape.Start or NodeShape.End => new Size(g.StartEndSize, g.StartEndSize), NodeShape.Class => Class(node, m, g, r, title), + NodeShape.Diamond => Diamond(node, m, g, r, title), + NodeShape.Parallelogram => Parallelogram(node, m, g, r, title), _ => IsGhost(node) ? Ghost(node, m, g, r, title) : Card(node, m, g, r, title, flowStatuses), }; } @@ -70,6 +77,18 @@ private static Size Card(NodeModel node, ITextMeasurer m, StyleGeometry g, FontR textH += g.TextGap + WrapLines(m, node.Subtitle, FontRole.CardSubtitle, avail, r) * g.CardSubLine; } + // Bulleted items — single rows (never wrapped), stacked at the class-compartment pitch. + if (node.Items.Count > 0) + { + textH += g.TextGap + node.Items.Count * g.ItemLine + (node.Items.Count - 1) * g.ItemGap; + } + + // Wrapped paragraph body in the card-subtitle font, wrapping at the same avail as the title. + if (node.Body != null) + { + textH += g.TextGap + WrapLines(m, node.Body, FontRole.CardSubtitle, avail, r) * g.BodyLine; + } + if (node.Status != null || flowStatuses is { Count: > 0 }) { textH += g.TextGap + g.StatusMt + g.StatusChipH; @@ -79,6 +98,90 @@ private static Size Card(NodeModel node, ITextMeasurer m, StyleGeometry g, FontR return new Size(Round(width), Round(content + g.CardPadY + g.MeasureBorder)); } + /// + /// A diamond (flowchart decision). The text column must fit the inscribed rectangle: for a + /// diamond whose bbox is w×h (half-diagonals w/2, h/2), the max-area centered axis-aligned rectangle + /// has half-extents w/4, h/4 — i.e. a centered rect of exactly w/2 × h/2. So to seat a padded text + /// block of width TW and height TH we need + /// w/2 ≥ TW + CardPadX + border → w = 2·(TW + CardPadX + border) + /// h/2 ≥ TH + CardPadY + border → h = 2·(TH + CardPadY + border) + /// i.e. the diamond is twice the padded text block on each axis. Text wraps against w/2 (the + /// inscribed rect's width, exposed by ), so drawn wrap == measured wrap. + /// + private static Size Diamond(NodeModel node, ITextMeasurer m, StyleGeometry g, FontRoleTable r, string title) + { + var width = DiamondWidth(node, m, g, r, title); + var avail = width / 2 - g.CardPadX - g.MeasureBorder; + var textH = WrapLines(m, title, FontRole.CardTitle, avail, r) * g.CardTitleLine; + if (node.Subtitle != null) + { + textH += g.TextGap + WrapLines(m, node.Subtitle, FontRole.CardSubtitle, avail, r) * g.CardSubLine; + } + + var height = 2 * (textH + g.CardPadY + g.MeasureBorder); + return new Size(Round(width), Round(height)); + } + + /// The diamond's bbox width: twice the padded single-line text block (so the inscribed rect + /// holds the widest row without wrapping), floored at ; an authored + /// width: is honoured (also floored at CardMinW). + private static double DiamondWidth(NodeModel node, ITextMeasurer m, StyleGeometry g, FontRoleTable r, string title) + { + if (node.Width is { } authored) + { + return Math.Max(g.CardMinW, authored); + } + + var titleW = W(m, r, title, FontRole.CardTitle); + var subW = node.Subtitle != null ? W(m, r, node.Subtitle, FontRole.CardSubtitle) : 0; + var block = Math.Max(titleW, subW); + var natural = Math.Ceiling(2 * (block + g.CardPadX + g.MeasureBorder)); + return Math.Max(g.CardMinW, natural); + } + + /// The horizontal space a diamond's centered text column wraps within — the inscribed rect's + /// width (w/2) less padding. Matches so the renderer draws the same wrap. + internal static double DiamondTextAvail(NodeModel node, ITextMeasurer m, StyleGeometry? geometry = null, FontRoleTable? roles = null, + string titlePrefix = "", string titleSuffix = "") + { + var g = geometry ?? BeckStyle.Classic.Geometry; + var r = roles ?? BeckStyle.Classic.Typography.Roles; + return DiamondWidth(node, m, g, r, Decorate(node.Title, titlePrefix, titleSuffix)) / 2 - g.CardPadX - g.MeasureBorder; + } + + /// + /// A parallelogram (flowchart I/O). Sizes the text exactly like a (no-icon) card, then widens the + /// bbox by the horizontal skew so the centered text column clears both slanted sides: + /// skew = min(12, h·0.4); width = cardWidth + skew + /// The skew is taken off the rounded height, matching the renderer (which reads the rounded rect + /// height back and recomputes the same skew). Text wraps against the card text column + /// (), unaffected by the skew, so drawn wrap == measured wrap. + /// + private static Size Parallelogram(NodeModel node, ITextMeasurer m, StyleGeometry g, FontRoleTable r, string title) + { + var cardW = CardWidth(node, m, 0, g, r, title); + var avail = cardW - g.CardPadX - g.MeasureBorder; + var textH = WrapLines(m, title, FontRole.CardTitle, avail, r) * g.CardTitleLine; + if (node.Subtitle != null) + { + textH += g.TextGap + WrapLines(m, node.Subtitle, FontRole.CardSubtitle, avail, r) * g.CardSubLine; + } + + var height = Round(textH + g.CardPadY + g.MeasureBorder); + var skew = Beck.Svg.Artwork.ParallelogramSkew(height); + return new Size(Round(cardW + skew), height); + } + + /// The horizontal space a parallelogram's centered text column wraps within — the card text + /// column (independent of the skew, which only widens the bbox). Matches . + internal static double ParallelogramTextAvail(NodeModel node, ITextMeasurer m, StyleGeometry? geometry = null, FontRoleTable? roles = null, + string titlePrefix = "", string titleSuffix = "") + { + var g = geometry ?? BeckStyle.Classic.Geometry; + var r = roles ?? BeckStyle.Classic.Typography.Roles; + return CardWidth(node, m, 0, g, r, Decorate(node.Title, titlePrefix, titleSuffix)) - g.CardPadX - g.MeasureBorder; + } + private static Size Pill(NodeModel node, ITextMeasurer m, StyleGeometry g, FontRoleTable r, string title) { var titleW = W(m, r, title, FontRole.PillTitle); @@ -207,6 +310,15 @@ private static double CardWidth(NodeModel node, ITextMeasurer m, double iconBloc var chrome = g.CardPadX + g.MeasureBorder + iconBlock; var titleW = W(m, r, title, FontRole.CardTitle); var subW = node.Subtitle != null ? W(m, r, node.Subtitle, FontRole.CardSubtitle) : 0; + // The card grows to hold the widest single-line item (bullet baked in) and the body's + // single-line width, both clamped by CardMaxW below (past which they wrap/overflow). + double itemW = 0; + foreach (var it in node.Items) + { + itemW = Math.Max(itemW, W(m, r, ItemBullet + it, FontRole.CardSubtitle)); + } + + var bodyW = node.Body != null ? W(m, r, node.Body, FontRole.CardSubtitle) : 0; // A flow status/fail step swaps a pill into this card; the box pre-grows to the widest // pill (text + 16px chip padding) since compiled CSS can't reflow the way the live-DOM // engine did. Statuses never wrap, so past CardMaxW a pill still overflows (like the JS @@ -221,7 +333,8 @@ private static double CardWidth(NodeModel node, ITextMeasurer m, double iconBloc } // Ceiling (not Round): the width must never shave the text column below the measured // single-line width, or the title would wrap inside a box sized to hold it on one line. - var natural = Math.Ceiling(Math.Max(Math.Max(titleW, subW), statusW)) + chrome; + var widest = Math.Max(Math.Max(Math.Max(titleW, subW), statusW), Math.Max(itemW, bodyW)); + var natural = Math.Ceiling(widest) + chrome; return Math.Clamp(natural, g.CardMinW, g.CardMaxW); } diff --git a/tests/Beck.Tests/CardItemsBodySizingTests.cs b/tests/Beck.Tests/CardItemsBodySizingTests.cs new file mode 100644 index 0000000..1d2a209 --- /dev/null +++ b/tests/Beck.Tests/CardItemsBodySizingTests.cs @@ -0,0 +1,159 @@ +using Beck.Layout; +using Beck.Model; +using Beck.Svg; +using Beck.Text; +using Xunit; + +namespace Beck.Tests; + +/// +/// Gates the items: (bulleted list) and body: (wrapped paragraph) terms of +/// 's card box-model math. Each item is one un-wrapped row prefixed with +/// (measured == drawn); the body wraps at the card's text-column +/// width exactly as the renderer draws it. A card without either measures byte-identically to before +/// (the blocks are inert when absent). Companion to and the +/// browser-parity . +/// +public sealed class CardItemsBodySizingTests +{ + private static readonly ITextMeasurer _m = InterMetricsMeasurer.Instance; + private static readonly StyleGeometry _geo = BeckStyle.Classic.Geometry; + private static readonly FontRoleTable _roles = BeckStyle.Classic.Typography.Roles; + + private static double W(string t, FontRole role) => _m.Measure(t, role, _roles.Of(role)).Width; + + private static double Round(double n) => Math.Floor(n + 0.5); // JS Math.round, matching Js.Round. + + private static NodeModel Node(string title, string[]? items = null, string? body = null, string? subtitle = null) + { + var seq = items is { Length: > 0 } + ? ", items: [" + string.Join(", ", items.Select(i => $"\"{i}\"")) + "]" + : ""; + var bod = body != null ? $", body: \"{body}\"" : ""; + var sub = subtitle != null ? $", subtitle: \"{subtitle}\"" : ""; + return Validate.LoadDiagram( + $"type: architecture\nnodes: [{{ id: a, title: \"{title}\"{sub}{seq}{bod} }}]\nedges: []\n" + ).Nodes[0]; + } + + /// An independent oracle for 's box model, including the new + /// items/body terms. Reduces to the pre-existing formula when both are absent. + private static Size Expected(NodeModel node) + { + var g = _geo; + var hasIcon = Icons.ResolveIcon(node.Icon) != null; + var iconBlock = hasIcon ? g.IconW + g.IconGap : 0; + var chrome = g.CardPadX + g.MeasureBorder + iconBlock; + + var titleW = W(node.Title, FontRole.CardTitle); + var subW = node.Subtitle != null ? W(node.Subtitle, FontRole.CardSubtitle) : 0; + double itemW = 0; + foreach (var it in node.Items) + { + itemW = Math.Max(itemW, W(CardSizer.ItemBullet + it, FontRole.CardSubtitle)); + } + + var bodyW = node.Body != null ? W(node.Body, FontRole.CardSubtitle) : 0; + var widest = Math.Max(Math.Max(Math.Max(titleW, subW), 0), Math.Max(itemW, bodyW)); + var width = Math.Clamp(Math.Ceiling(widest) + chrome, g.CardMinW, g.CardMaxW); + var avail = width - g.CardPadX - g.MeasureBorder - iconBlock; + + var textH = CardSizer.WrapText(_m, node.Title, FontRole.CardTitle, avail, _roles).Count * g.CardTitleLine; + if (node.Subtitle != null) + { + textH += g.TextGap + CardSizer.WrapText(_m, node.Subtitle, FontRole.CardSubtitle, avail, _roles).Count * g.CardSubLine; + } + + if (node.Items.Count > 0) + { + textH += g.TextGap + node.Items.Count * g.ItemLine + (node.Items.Count - 1) * g.ItemGap; + } + + if (node.Body != null) + { + textH += g.TextGap + CardSizer.WrapText(_m, node.Body, FontRole.CardSubtitle, avail, _roles).Count * g.BodyLine; + } + + var content = Math.Max(hasIcon ? g.IconW : 0, textH); + return new Size(Round(width), Round(content + g.CardPadY + g.MeasureBorder)); + } + + [Fact] + public void TitlePlusItems_MatchesBoxModel() + { + var node = Node("Gateway Service", items: ["Validate token", "Rate limit", "Route request"]); + Assert.Equal(3, node.Items.Count); + Assert.Equal(Expected(node), CardSizer.Measure(node, _m, _geo)); + } + + [Fact] + public void ItemsBlockHeight_IsTextGapPlusRowPitch() + { + // Pin the items term precisely: TextGap + n·ItemLine + (n-1)·ItemGap, added to the + // title+subtitle stack. Computed in unrounded content space (the box rounds only once). + var g = _geo; + var withItems = Node("Gateway", subtitle: "edge tier", items: ["A longer bullet line", "Second", "Third"]); + var avail = CardSizer.CardTextAvail(withItems, _m, g); + + var baseTextH = CardSizer.WrapText(_m, "Gateway", FontRole.CardTitle, avail, _roles).Count * g.CardTitleLine + + g.TextGap + CardSizer.WrapText(_m, "edge tier", FontRole.CardSubtitle, avail, _roles).Count * g.CardSubLine; + var itemsH = g.TextGap + 3 * g.ItemLine + 2 * g.ItemGap; + var content = Math.Max(g.IconW, baseTextH + itemsH); + + Assert.True(baseTextH + itemsH > g.IconW, "precondition: card is text-dominated, not icon-dominated"); + Assert.Equal(Round(content + g.CardPadY + g.MeasureBorder), CardSizer.Measure(withItems, _m, g).H); + } + + [Fact] + public void TitlePlusBody_WrapsAtCardMaxW() + { + // A body far wider than CardMaxW must clamp the card to CardMaxW and wrap to several lines. + const string Body = "This is a deliberately long body paragraph that must wrap across several lines inside the card once the width clamps at the maximum."; + var node = Node("Notes", body: Body); + var size = CardSizer.Measure(node, _m, _geo); + + Assert.Equal(_geo.CardMaxW, size.W); // clamped + var avail = CardSizer.CardTextAvail(node, _m, _geo); + Assert.True(CardSizer.WrapText(_m, Body, FontRole.CardSubtitle, avail, _roles).Count > 1, "body must wrap"); + Assert.Equal(Expected(node), size); + } + + [Fact] + public void TitleItemsAndBody_Combined_MatchesBoxModel() + { + var node = Node("Order Pipeline", + subtitle: "async", + items: ["Receive", "Enrich", "Persist"], + body: "Each stage acknowledges before the next begins, so a failure rewinds cleanly."); + Assert.Equal(Expected(node), CardSizer.Measure(node, _m, _geo)); + } + + [Fact] + public void NoItemsNoBody_MeasuresExactlyAsBefore() + { + // The new paths are inert when absent: a plain card (and a title+subtitle+status card) reduce + // to the pre-existing formula (the Expected oracle collapses to the old terms). + var plain = Node("API"); + Assert.Empty(plain.Items); + Assert.Null(plain.Body); + Assert.Equal(Expected(plain), CardSizer.Measure(plain, _m, _geo)); + + var withSub = Node("API", subtitle: "gateway"); + Assert.Equal(Expected(withSub), CardSizer.Measure(withSub, _m, _geo)); + } + + [Fact] + public void Render_EmitsGuardedItemAndBodyText() + { + const string Body = "A short body line under the bullets."; + var yaml = "type: architecture\n" + + "nodes: [{ id: a, title: Topic, items: [\"Alpha point\", \"Beta point\"], body: \"" + Body + "\" }]\n" + + "edges: []\n"; + var svg = BeckSvg.Render(yaml); // default InterMetricsMeasurer → approximate → textLength guards + + Assert.Contains(CardSizer.ItemBullet + "Alpha point", svg); + Assert.Contains(CardSizer.ItemBullet + "Beta point", svg); + Assert.Contains("A short body line", svg); + Assert.Contains("textLength=", svg); + } +} diff --git a/tests/Beck.Tests/DiamondParallelogramTests.cs b/tests/Beck.Tests/DiamondParallelogramTests.cs new file mode 100644 index 0000000..0389986 --- /dev/null +++ b/tests/Beck.Tests/DiamondParallelogramTests.cs @@ -0,0 +1,160 @@ +using Beck.Layout; +using Beck.Model; +using Beck.Route; +using Beck.Svg; +using Beck.Tests.CleanLines; +using Beck.Text; +using Xunit; + +namespace Beck.Tests; + +/// +/// Gates the diamond/parallelogram node primitives (flowchart groundwork, rendered from architecture +/// YAML via shape: diamond/parallelogram): the box-model math +/// (inscribed-rect for the diamond, card + skew for the parallelogram), the point-anchored router +/// contract (diamond anchors pinned to their vertices, never spread), and a render smoke check. +/// +public sealed class DiamondParallelogramTests +{ + private static readonly ITextMeasurer _m = InterMetricsMeasurer.Instance; + private static readonly StyleGeometry _geo = BeckStyle.Classic.Geometry; + private static readonly FontRoleTable _roles = BeckStyle.Classic.Typography.Roles; + + private static NodeModel Node(string shape, string title, string? subtitle = null) => + Validate.LoadDiagram( + $"type: architecture\nnodes: [{{ id: a, title: \"{title}\", shape: {shape}" + + $"{(subtitle != null ? $", subtitle: \"{subtitle}\"" : "")} }}]\nedges: []\n").Nodes[0]; + + private static double TitleW(string s) => _m.Measure(s, FontRole.CardTitle, _roles.Of(FontRole.CardTitle)).Width; + + // ---- shape parsing ---- + + [Fact] + public void ShapeTokens_Parse_OnArchitectureNodes() + { + Assert.Equal(NodeShape.Diamond, Node("diamond", "D").Shape); + Assert.Equal(NodeShape.Parallelogram, Node("parallelogram", "P").Shape); + // Absent `shape:` still defaults to Card (existing documents unchanged). + Assert.Equal(NodeShape.Card, + Validate.LoadDiagram("type: architecture\nnodes: [{ id: a, title: A }]\nedges: []\n").Nodes[0].Shape); + } + + // ---- diamond sizing: the inscribed-rect equation ---- + + [Fact] + public void Diamond_SizesAtTwiceThePaddedTextBlock() + { + var node = Node("diamond", "Decision"); + var size = CardSizer.Measure(node, _m, _geo); + + // width = 2·(widest single-line text + CardPadX + border), floored at CardMinW. + var expectedW = Math.Max(_geo.CardMinW, + Math.Ceiling(2 * (TitleW("Decision") + _geo.CardPadX + _geo.MeasureBorder))); + Assert.Equal(Js.Round(expectedW), size.W); + + // height = 2·(wrapped text height + CardPadY + border). + var avail = expectedW / 2 - _geo.CardPadX - _geo.MeasureBorder; + var lines = CardSizer.WrapText(_m, "Decision", FontRole.CardTitle, avail, _roles).Count; + var textH = lines * _geo.CardTitleLine; + var expectedH = 2 * (textH + _geo.CardPadY + _geo.MeasureBorder); + Assert.Equal(Js.Round(expectedH), size.H); + } + + [Fact] + public void Diamond_InscribedRect_HoldsTheTitleOnOneLine() + { + // The whole point of the 2× rule: a centered rect of w/2 (the avail the renderer wraps within) + // is inscribed in the diamond and comfortably holds the widest single-line row. + var node = Node("diamond", "Decision"); + var avail = CardSizer.DiamondTextAvail(node, _m, _geo, _roles); + Assert.True(avail >= TitleW("Decision"), $"inscribed width {avail} must hold title {TitleW("Decision")}"); + Assert.Equal(new[] { "Decision" }, CardSizer.WrapText(_m, "Decision", FontRole.CardTitle, avail, _roles)); + } + + [Fact] + public void Diamond_TextAvail_IsHalfTheWidthLessPadding() + { + var node = Node("diamond", "Decision", subtitle: "choose a branch"); + var size = CardSizer.Measure(node, _m, _geo); + Assert.Equal(size.W / 2 - _geo.CardPadX - _geo.MeasureBorder, + CardSizer.DiamondTextAvail(node, _m, _geo, _roles)); + } + + // ---- parallelogram sizing: card math + skew ---- + + [Fact] + public void Parallelogram_IsCardWidthPlusSkew() + { + var node = Node("parallelogram", "Read input"); + var size = CardSizer.Measure(node, _m, _geo); + + // Card auto-grow width (no icon block), clamped to [CardMinW, CardMaxW]. + var cardW = Math.Clamp(Math.Ceiling(TitleW("Read input")) + _geo.CardPadX + _geo.MeasureBorder, + _geo.CardMinW, _geo.CardMaxW); + var avail = cardW - _geo.CardPadX - _geo.MeasureBorder; + var textH = CardSizer.WrapText(_m, "Read input", FontRole.CardTitle, avail, _roles).Count * _geo.CardTitleLine; + var expectedH = Js.Round(textH + _geo.CardPadY + _geo.MeasureBorder); + var skew = Artwork.ParallelogramSkew(expectedH); + + Assert.Equal(expectedH, size.H); + Assert.Equal(Js.Round(cardW + skew), size.W); + // The skew genuinely widened the box beyond the card text column. + Assert.True(skew > 0); + Assert.Equal(skew, Math.Min(12, size.H * 0.4)); + // The renderer's wrap width tracks the card text column (independent of the skew). + Assert.Equal(avail, CardSizer.ParallelogramTextAvail(node, _m, _geo, _roles)); + } + + // ---- router: diamond anchors land exactly on the bbox face midpoints (vertices) ---- + + [Fact] + public void Diamond_FanIn_AnchorsLandExactlyOnFaceMidpoints_NotSpread() + { + const string yaml = """ + type: architecture + meta: { direction: TB } + nodes: + - { id: a, title: Source A } + - { id: b, title: Source B } + - { id: c, title: Source C } + - { id: d, title: Decision, shape: diamond } + edges: + - { from: a, to: d } + - { from: b, to: d } + - { from: c, to: d } + """; + + var (_, layout, edges) = LineQuality.Route(yaml); + var dia = layout.Nodes["d"]; + var topVertex = new Point(dia.X + dia.W / 2, dia.Y); + + // All three edges arrive on the diamond's TOP face; each must terminate EXACTLY on the top + // vertex (the bbox top face midpoint), not fanned out to distinct anchor slots. + var toDia = edges.Where(e => e.Edge.To == "d").ToList(); + Assert.Equal(3, toDia.Count); + foreach (var e in toDia) + { + var end = e.Points[^1]; + Assert.True(Math.Abs(end.X - topVertex.X) < 0.01 && Math.Abs(end.Y - topVertex.Y) < 0.01, + $"edge {e.Edge.Id} ends at ({end.X:0.###},{end.Y:0.###}), expected vertex ({topVertex.X:0.###},{topVertex.Y:0.###})"); + } + } + + // ---- render smoke ---- + + [Fact] + public void Render_EmitsDiamondAndParallelogramPaths() + { + var diamondSvg = BeckSvg.Render( + "type: architecture\nnodes: [{ id: a, title: Decision, shape: diamond }]\nedges: []\n"); + Assert.StartsWith(" Date: Sat, 11 Jul 2026 16:21:33 -0400 Subject: [PATCH 2/4] feat: flowchart and mindmap diagram types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new root `type:`s on the shared layered engine: - `flowchart` — process/decision graphs (process cards, decision diamonds, I/O parallelograms, start/end terminators) joined by labelled `links:`, with `"[*]"` start/end pseudo-steps. - `mindmap` — radial topic trees with items and body copy. Both ship a model builder and a fluent authoring builder, and reuse the existing measure/route/svg/animate stages. Shared engine work supports both: diamond/parallelogram shapes and loop-back routing (EdgePainter, OrthogonalRouter, SvgRenderer, CardSizer), plus token/schema/enum plumbing. Includes guide + reference docs, playground examples for mindmap, corpus diagrams, and model/SVG goldens; clean-line fuzzer coverage extended to the new shapes. Full suite green (505 tests). --- docs/Beck.Docs.Client/PlaygroundIsland.razor | 5 + docs/Beck.Docs/BrandStyling.cs | 2 + .../Content/docs/guides/custom-styles.md | 2 +- docs/Beck.Docs/Content/docs/guides/flow.md | 2 +- .../Content/docs/guides/flowchart.md | 89 ++++ .../Beck.Docs/Content/docs/guides/generate.md | 2 +- docs/Beck.Docs/Content/docs/guides/mindmap.md | 109 +++++ docs/Beck.Docs/Content/docs/guides/nodes.md | 15 + docs/Beck.Docs/Content/docs/guides/styles.md | 2 +- docs/Beck.Docs/Content/docs/guides/theme.md | 2 +- docs/Beck.Docs/Content/docs/reference/flow.md | 2 +- .../Content/docs/reference/gallery.md | 2 +- .../Beck.Docs/Content/docs/reference/icons.md | 2 +- .../Content/docs/reference/styles.md | 2 +- docs/Beck.Docs/Content/docs/reference/yaml.md | 84 +++- docs/Beck.Docs/Content/for-ai.llms.md | 78 +++- .../examples/guides/flowchart-01.beck.yaml | 8 + .../examples/guides/flowchart-02.beck.yaml | 16 + .../examples/guides/flowchart-03.beck.yaml | 10 + .../examples/guides/mindmap-01.beck.yaml | 11 + .../examples/guides/mindmap-02.beck.yaml | 28 ++ .../examples/guides/nodes-10.beck.yaml | 7 + .../wwwroot/examples/mindmap-beck.beck.yaml | 42 ++ .../examples/mindmap-roadmap.beck.yaml | 35 ++ .../wwwroot/examples/mindmap-skills.beck.yaml | 47 ++ src/Beck.Sample/Program.cs | 41 +- src/Beck/Authoring/BeckSchema.cs | 32 +- src/Beck/Authoring/Enums.cs | 20 +- src/Beck/Authoring/FlowchartBuilder.cs | 403 ++++++++++++++++++ src/Beck/Authoring/MindMapBuilder.cs | 329 ++++++++++++++ src/Beck/Layout/LayeredLayout.cs | 6 +- src/Beck/Layout/MindMapLayout.cs | 161 +++++++ src/Beck/Model/Defaults.cs | 5 + src/Beck/Model/FlowchartBuilder.cs | 229 ++++++++++ src/Beck/Model/MindMapBuilder.cs | 206 +++++++++ src/Beck/Model/Tokens.cs | 6 +- src/Beck/Model/Validate.cs | 2 + src/Beck/Route/EdgePainter.cs | 207 ++++++++- src/Beck/Route/OrthogonalRouter.cs | 62 ++- src/Beck/Svg/Stylesheet.cs | 12 +- src/Beck/Svg/SvgRenderer.cs | 142 +++++- src/Beck/Text/CardSizer.cs | 48 ++- tests/Beck.Tests/ClassicSvgGoldenTests.cs | 7 +- tests/Beck.Tests/CleanLines/CleanLineTests.cs | 270 ++++++++++-- tests/Beck.Tests/CleanLines/DiagramFuzzer.cs | 260 +++++++++++ tests/Beck.Tests/CleanLines/LineQuality.cs | 24 +- tests/Beck.Tests/CleanLines/MindmapQuality.cs | 218 ++++++++++ .../Beck.Tests/Corpus/flowchart-branchy.yaml | 24 ++ tests/Beck.Tests/Corpus/flowchart-simple.yaml | 11 + tests/Beck.Tests/Corpus/mindmap-kitchen.yaml | 39 ++ tests/Beck.Tests/Corpus/mindmap-simple.yaml | 13 + tests/Beck.Tests/Corpus/mindmap-status.yaml | 32 ++ tests/Beck.Tests/DiamondParallelogramTests.cs | 53 +++ tests/Beck.Tests/FlowchartBuilderTests.cs | 208 +++++++++ tests/Beck.Tests/FlowchartTests.cs | 145 +++++++ tests/Beck.Tests/Goldens/cleanlines.json | 27 +- .../model/flowchart-branchy.model.json | 1 + .../Goldens/model/flowchart-simple.model.json | 1 + .../Goldens/model/mindmap-kitchen.model.json | 1 + .../Goldens/model/mindmap-simple.model.json | 1 + .../Goldens/model/mindmap-status.model.json | 1 + .../Goldens/svg/flowchart-branchy.svg | 1 + .../Goldens/svg/flowchart-simple.svg | 1 + .../Goldens/svg/mindmap-kitchen.svg | 1 + .../Beck.Tests/Goldens/svg/mindmap-simple.svg | 1 + .../Beck.Tests/Goldens/svg/mindmap-status.svg | 1 + tests/Beck.Tests/MindMapBuilderTests.cs | 193 +++++++++ tests/Beck.Tests/MindMapTests.cs | 323 ++++++++++++++ tests/Beck.Tests/ModelParityTests.cs | 24 ++ tests/Beck.Tests/RegenGoldens.cs | 4 + tests/Beck.Tests/RenderSmokeTests.cs | 2 +- 71 files changed, 4312 insertions(+), 90 deletions(-) create mode 100644 docs/Beck.Docs/Content/docs/guides/flowchart.md create mode 100644 docs/Beck.Docs/Content/docs/guides/mindmap.md create mode 100644 docs/Beck.Docs/wwwroot/examples/guides/flowchart-01.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/guides/flowchart-02.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/guides/flowchart-03.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/guides/mindmap-01.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/guides/mindmap-02.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/guides/nodes-10.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/mindmap-beck.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/mindmap-roadmap.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/mindmap-skills.beck.yaml create mode 100644 src/Beck/Authoring/FlowchartBuilder.cs create mode 100644 src/Beck/Authoring/MindMapBuilder.cs create mode 100644 src/Beck/Layout/MindMapLayout.cs create mode 100644 src/Beck/Model/FlowchartBuilder.cs create mode 100644 src/Beck/Model/MindMapBuilder.cs create mode 100644 tests/Beck.Tests/CleanLines/MindmapQuality.cs create mode 100644 tests/Beck.Tests/Corpus/flowchart-branchy.yaml create mode 100644 tests/Beck.Tests/Corpus/flowchart-simple.yaml create mode 100644 tests/Beck.Tests/Corpus/mindmap-kitchen.yaml create mode 100644 tests/Beck.Tests/Corpus/mindmap-simple.yaml create mode 100644 tests/Beck.Tests/Corpus/mindmap-status.yaml create mode 100644 tests/Beck.Tests/FlowchartBuilderTests.cs create mode 100644 tests/Beck.Tests/FlowchartTests.cs create mode 100644 tests/Beck.Tests/Goldens/model/flowchart-branchy.model.json create mode 100644 tests/Beck.Tests/Goldens/model/flowchart-simple.model.json create mode 100644 tests/Beck.Tests/Goldens/model/mindmap-kitchen.model.json create mode 100644 tests/Beck.Tests/Goldens/model/mindmap-simple.model.json create mode 100644 tests/Beck.Tests/Goldens/model/mindmap-status.model.json create mode 100644 tests/Beck.Tests/Goldens/svg/flowchart-branchy.svg create mode 100644 tests/Beck.Tests/Goldens/svg/flowchart-simple.svg create mode 100644 tests/Beck.Tests/Goldens/svg/mindmap-kitchen.svg create mode 100644 tests/Beck.Tests/Goldens/svg/mindmap-simple.svg create mode 100644 tests/Beck.Tests/Goldens/svg/mindmap-status.svg create mode 100644 tests/Beck.Tests/MindMapBuilderTests.cs create mode 100644 tests/Beck.Tests/MindMapTests.cs diff --git a/docs/Beck.Docs.Client/PlaygroundIsland.razor b/docs/Beck.Docs.Client/PlaygroundIsland.razor index c50f79a..0f3375e 100644 --- a/docs/Beck.Docs.Client/PlaygroundIsland.razor +++ b/docs/Beck.Docs.Client/PlaygroundIsland.razor @@ -525,6 +525,11 @@ new("class", "Class", "▤", "class", [ new Ex("Domain model", "Entities and relations in an order domain", "/examples/domain-model.beck.yaml"), new Ex("Payment gateways", "A gateway interface with provider implementations", "/examples/payment-gateways.beck.yaml") + ]), + new("mindmap", "Mindmap", "❋", "map", [ + new Ex("Product roadmap", "Branch statuses and a ghosted future launch", "/examples/mindmap-roadmap.beck.yaml"), + new Ex("How Beck works", "The engine as a topic tree, with items and body copy", "/examples/mindmap-beck.beck.yaml"), + new Ex("Frontend roadmap", "A wide learning path across the accent cycle", "/examples/mindmap-skills.beck.yaml") ]) ]; diff --git a/docs/Beck.Docs/BrandStyling.cs b/docs/Beck.Docs/BrandStyling.cs index 1a9d3ff..d7bf4c4 100644 --- a/docs/Beck.Docs/BrandStyling.cs +++ b/docs/Beck.Docs/BrandStyling.cs @@ -303,10 +303,12 @@ items and several are toggled from JS (is-open / is-active). */ .pg-pill[data-type="sequence"] { color: var(--color-sky-700, #0369a1); background: color-mix(in srgb, var(--color-sky-500, #0ea5e9) 14%, transparent); border-color: color-mix(in srgb, var(--color-sky-500, #0ea5e9) 30%, transparent); } .pg-pill[data-type="state"] { color: #b45309; background: color-mix(in srgb, #f59e0b 16%, transparent); border-color: color-mix(in srgb, #f59e0b 32%, transparent); } .pg-pill[data-type="class"] { color: #6d28d9; background: color-mix(in srgb, #8b5cf6 16%, transparent); border-color: color-mix(in srgb, #8b5cf6 32%, transparent); } + .pg-pill[data-type="mindmap"] { color: #be123c; background: color-mix(in srgb, #f43f5e 14%, transparent); border-color: color-mix(in srgb, #f43f5e 30%, transparent); } .dark .pg-pill[data-type="architecture"] { color: var(--color-emerald-300, #6ee7b7); } .dark .pg-pill[data-type="sequence"] { color: var(--color-sky-300, #7dd3fc); } .dark .pg-pill[data-type="state"] { color: #fcd34d; } .dark .pg-pill[data-type="class"] { color: #c4b5fd; } + .dark .pg-pill[data-type="mindmap"] { color: #fda4af; } /* Colour-scheme swatch: a two-tone chip previewing each palette's signature. */ .pg-swatch { flex: none; width: 15px; height: 15px; border-radius: 4px; border: 1px solid rgb(0 0 0 / .18); } diff --git a/docs/Beck.Docs/Content/docs/guides/custom-styles.md b/docs/Beck.Docs/Content/docs/guides/custom-styles.md index c07baff..2f41d28 100644 --- a/docs/Beck.Docs/Content/docs/guides/custom-styles.md +++ b/docs/Beck.Docs/Content/docs/guides/custom-styles.md @@ -1,7 +1,7 @@ --- title: Author a custom style description: Derive a BeckStyle from a built-in with a with-expression, register it, and drive it from meta.style — retinting tokens, choosing a metrics font, and composing an artwork. -order: 33 +order: 35 sectionLabel: Cross-cutting uid: docs.guide.custom-styles --- diff --git a/docs/Beck.Docs/Content/docs/guides/flow.md b/docs/Beck.Docs/Content/docs/guides/flow.md index f6b1cf2..52bf18e 100644 --- a/docs/Beck.Docs/Content/docs/guides/flow.md +++ b/docs/Beck.Docs/Content/docs/guides/flow.md @@ -1,7 +1,7 @@ --- title: Animate the flow description: Script packets, bursts, status pills, and effects to tell a story with motion. -order: 30 +order: 32 sectionLabel: Cross-cutting uid: docs.guide.flow --- diff --git a/docs/Beck.Docs/Content/docs/guides/flowchart.md b/docs/Beck.Docs/Content/docs/guides/flowchart.md new file mode 100644 index 0000000..c04d24a --- /dev/null +++ b/docs/Beck.Docs/Content/docs/guides/flowchart.md @@ -0,0 +1,89 @@ +--- +title: Draw a flowchart +description: Process cards, decision diamonds, I/O parallelograms, and start/end terminators, joined by labelled links. +order: 29 +sectionLabel: Other diagram types +uid: docs.guide.flowchart +--- + +A `type: flowchart` document draws a classic process/decision graph — steps as process cards, +decision diamonds, I/O parallelograms, and pill-shaped terminators, joined by labelled `links:`. It +runs on the same layered engine as architecture diagrams (so `direction: LR` reads like a pipeline +and `TB` like a procedure), and the derived animation walks a packet through the links in declared +order. + +## The tersest flowchart + +You don't have to declare steps at all — any id a link mentions is auto-created as a plain +`process` card. `"[*]"` is the start/end pseudo-step (quote it — the brackets are YAML syntax +otherwise): a link *from* `"[*]"` draws the start terminator, one *to* `"[*]"` draws the end +terminator, exactly like the `"[*]"` entry/exit pseudo-state in [state diagrams](/docs/guides/state): + +```yaml:symbol +wwwroot/examples/guides/flowchart-01.beck.yaml +``` + +```beck:symbol +wwwroot/examples/guides/flowchart-01.beck.yaml +``` + +## Decisions, I/O, and a loop back + +Add a `steps:` list to give a step a real `kind`. `decision` renders a diamond — pair it with two +links carrying `yes`/`no` labels for the branches. `io` renders a parallelogram for +read/write-shaped steps. A link back to an earlier step (here, `retry` routing back to `check`) +draws as a loop instead of crossing the forward flow: + +```yaml:symbol +wwwroot/examples/guides/flowchart-02.beck.yaml +``` + +```beck:symbol +wwwroot/examples/guides/flowchart-02.beck.yaml +``` + +> [!NOTE] +> Every `kind` — `process`, `decision`, `terminator`, `io`, `start`, `end` — defaults to a neutral +> accent. Set `accent` per step (a [colour token](/docs/reference/yaml#colours-and-theme-tokens) or +> raw CSS colour) to make a branch or an outcome read as success, danger, or a brand hue. + +## Labels, accents, and narration + +`links:` fields work like architecture `edges:` — `label`, `style` (`solid`/`dashed`), `color`, and +a `note:` that narrates the hop in a [derived flow](/docs/reference/flow#derived-flow): + +```yaml:symbol +wwwroot/examples/guides/flowchart-03.beck.yaml +``` + +```beck:symbol +wwwroot/examples/guides/flowchart-03.beck.yaml +``` + +## Scripting the animation + +Without a `flow:`, the engine walks `links:` in declared order — one packet per link. For a guided +story — highlight a branch, `fail` a step, park a `status` pill — add a `flow:` block; step ids +work anywhere node ids do. See [Animate the flow](/docs/guides/flow). + +## Generate it from your C# + +```csharp +using Beck.Authoring; + +string fence = new FlowchartDiagramBuilder("Checkout") + .Direction(Direction.Tb) + .Decision("valid", "Payment valid?") + .Link(FlowchartDiagramBuilder.Pseudo, "valid") + .Link("valid", "charge", "yes") + .Link("valid", "retry", "no") + .Link("charge", FlowchartDiagramBuilder.Pseudo) + .Link("retry", "valid") + .ToFence(); // ```beck … ``` — drop it into any Markdown page +``` + +--- + +Full field tables: [steps and links in the YAML +schema](/docs/reference/yaml#steps-and-links-type-flowchart). Generating one from C#: +[`FlowchartDiagramBuilder`](/docs/guides/generate). diff --git a/docs/Beck.Docs/Content/docs/guides/generate.md b/docs/Beck.Docs/Content/docs/guides/generate.md index 17a0b3a..6558488 100644 --- a/docs/Beck.Docs/Content/docs/guides/generate.md +++ b/docs/Beck.Docs/Content/docs/guides/generate.md @@ -1,7 +1,7 @@ --- title: Generate diagrams from your code description: Build diagrams with the C# DiagramBuilder so they regenerate from your real model. -order: 31 +order: 33 sectionLabel: Cross-cutting uid: docs.guide.generate --- diff --git a/docs/Beck.Docs/Content/docs/guides/mindmap.md b/docs/Beck.Docs/Content/docs/guides/mindmap.md new file mode 100644 index 0000000..e4bdc3d --- /dev/null +++ b/docs/Beck.Docs/Content/docs/guides/mindmap.md @@ -0,0 +1,109 @@ +--- +title: Draw a mind map +description: A nested topic tree drawn as a two-sided butterfly, with depth roles, accent cycling, status pills, and ghost branches. +order: 30 +sectionLabel: Other diagram types +uid: docs.guide.mindmap +--- + +A `type: mindmap` document draws a nested topic tree as a two-sided "butterfly": a central `root`, +first-level branches split left and right, and every deeper subtree fanning outward from its parent. +Depth reads as **size and shape** — a big root card, medium rank-1 branch cards with icon chips, and +light leaf pills further out — while each top branch carries its own accent down its whole subtree. +The layout is fixed (there's no `direction` to choose), and a mind map renders **static**: it's a map +to read, so there are no packets or narration. + +## The shape: root and topics + +Every mind map needs a `root` and a `topics:` list of first-level branches. `root` can be a plain +string (shorthand for its title) or a mapping for a subtitle, accent, icon, or content. Each +first-level branch takes the next colour from the cycle **info → primary → success → warn → danger** +(wrapping), so a wide map still reads as distinct threads without you naming a colour for every +branch — and every descendant inherits its branch's colour: + +```yaml:symbol +wwwroot/examples/guides/mindmap-01.beck.yaml +``` + +```beck:symbol +wwwroot/examples/guides/mindmap-01.beck.yaml +``` + +## Nesting, items, body, and accent overrides + +Nest with `children:` to any depth. Shape follows depth: the **root** and every **rank-1** branch are +cards (a rank-1 card shows an icon chip when you set `icon:`); from **rank 2 outward** a heading is a +light pill. Give any topic real content with `items:` (a bulleted list) or `body:` (a wrapped +paragraph) and it stays a card at any depth. Every descendant inherits its parent's resolved accent — +set `accent:` explicitly on a topic to start a new colour that then flows to *its* children: + +```yaml:symbol +wwwroot/examples/guides/mindmap-02.beck.yaml +``` + +```beck:symbol +wwwroot/examples/guides/mindmap-02.beck.yaml +``` + +> [!NOTE] +> `items:`/`body:` aren't unique to mind maps — architecture cards accept the same two fields. See +> [Add items or a body](/docs/guides/nodes#add-items-or-a-body) in the nodes guide. + +## Status pills and ghost branches + +Give a rank-1 branch a `status:` and it renders a small semantic pill under the title — the colour +comes from the word, not the branch accent: `complete`/`done` read success, `in progress` reads warn, +`blocked` reads danger, `review` reads info, `planned` reads neutral. + +Mark a branch that isn't real yet with `variant: ghost` (or `ghost: true`). The whole subtree turns +neutral, dashed, and shadowless with a faint `planned` label — a clean way to sketch future work +without it competing with the committed branches. + +```yaml +topics: + - title: Research + icon: search + status: complete + - title: Launch + icon: cloud + ghost: true + children: [{ title: Beta program }, { title: Marketing site }] +``` + +## Layout + +The butterfly layout is fixed left/right — `meta.direction` is accepted but ignored, since a mind map +only reads one way. Top branches alternate right/left in authoring order, balancing leaf rows per +side; each branch sits at the mean height of its leaves. Edges run parent → child as smooth un-arrowed +curves in a muted branch colour (a mind map is read, not followed), fanning from a single point on +each parent so its children read as one set. + +A mind map renders static — no packets, no narration — identical to the reduced-motion frame. (A +`flow:` is still accepted for forward-compatibility, but a mind map doesn't animate it.) + +## Generate it from your C# + +```csharp +using Beck.Authoring; + +string fence = new MindMapDiagramBuilder("Beck") + .Root("Beck") + .Topic("Rendering", t => t + .Accent(AccentToken.Info) + .Status("complete") + .Topic("Pipeline", p => p.Items("Model", "Text", "Layout")) + .Topic("Determinism", d => d.Body("Same YAML, same SVG."))) + .Topic("Packages", t => t + .Topic("Beck") + .Topic("Beck.Skia")) + .Topic("Roadmap", t => t.Ghost() // a not-yet-real branch: neutral, dashed, "planned" + .Topic("Plugins") + .Topic("Themes")) + .ToFence(); // ```beck … ``` — drop it into any Markdown page +``` + +--- + +Full field tables: [root and topics in the YAML +schema](/docs/reference/yaml#root-and-topics-type-mindmap). Generating one from C#: +[`MindMapDiagramBuilder`](/docs/guides/generate). diff --git a/docs/Beck.Docs/Content/docs/guides/nodes.md b/docs/Beck.Docs/Content/docs/guides/nodes.md index 067e3d8..b270d68 100644 --- a/docs/Beck.Docs/Content/docs/guides/nodes.md +++ b/docs/Beck.Docs/Content/docs/guides/nodes.md @@ -118,6 +118,21 @@ wwwroot/examples/guides/nodes-07.beck.yaml A `status` set here is the node's resting state. If you want a status that **changes** while the diagram plays — flipping from `idle` to `busy`, say — that's a flow concern; see [Animate the flow](/docs/guides/flow). +## Add items or a body + +`items` renders a bulleted list inside the card; `body` renders a wrapped paragraph under it. Both +grow the card to fit their content — useful for an endpoint list, a changelog note, or any card that +needs more than a title and a subtitle. [Mind map topics](/docs/guides/mindmap) use the same two +fields for the same purpose. + +```yaml:symbol +wwwroot/examples/guides/nodes-10.beck.yaml +``` + +```beck:symbol,static +wwwroot/examples/guides/nodes-10.beck.yaml +``` + ## Make a node a link To turn a card into a hyperlink, add `href`. Beck renders the whole node as an ``. Add `target: _blank` to open in a new tab. diff --git a/docs/Beck.Docs/Content/docs/guides/styles.md b/docs/Beck.Docs/Content/docs/guides/styles.md index 9a2710d..991d900 100644 --- a/docs/Beck.Docs/Content/docs/guides/styles.md +++ b/docs/Beck.Docs/Content/docs/guides/styles.md @@ -1,7 +1,7 @@ --- title: Pick a built-in style description: The nine built-in styles — classic, minimal, terminal, blueprint, glow, brutalist, sketch, extrude, and circuit — each shown across an architecture, a sequence, and a class diagram. -order: 32 +order: 34 sectionLabel: Cross-cutting uid: docs.guide.styles --- diff --git a/docs/Beck.Docs/Content/docs/guides/theme.md b/docs/Beck.Docs/Content/docs/guides/theme.md index 8c86f0b..4b25ea2 100644 --- a/docs/Beck.Docs/Content/docs/guides/theme.md +++ b/docs/Beck.Docs/Content/docs/guides/theme.md @@ -1,7 +1,7 @@ --- title: Match your theme and colours description: How diagrams adopt your palette, choosing a theme mode, and overriding tokens. -order: 29 +order: 31 sectionLabel: Cross-cutting uid: docs.guide.theme --- diff --git a/docs/Beck.Docs/Content/docs/reference/flow.md b/docs/Beck.Docs/Content/docs/reference/flow.md index 9f7f706..803bbf8 100644 --- a/docs/Beck.Docs/Content/docs/reference/flow.md +++ b/docs/Beck.Docs/Content/docs/reference/flow.md @@ -1,7 +1,7 @@ --- title: Flow & animation description: The flow block, every step type, and the knobs that shape a travelling packet. -order: 41 +order: 43 sectionLabel: Reference uid: docs.reference.flow --- diff --git a/docs/Beck.Docs/Content/docs/reference/gallery.md b/docs/Beck.Docs/Content/docs/reference/gallery.md index 28853ba..fc7ee9c 100644 --- a/docs/Beck.Docs/Content/docs/reference/gallery.md +++ b/docs/Beck.Docs/Content/docs/reference/gallery.md @@ -1,7 +1,7 @@ --- title: Visual reference description: Every node kind, edge kind, accent, curve, relation kind, and direction — pulled from the source and shown live. -order: 42 +order: 44 sectionLabel: Reference uid: docs.reference.gallery --- diff --git a/docs/Beck.Docs/Content/docs/reference/icons.md b/docs/Beck.Docs/Content/docs/reference/icons.md index 04255b9..080a24e 100644 --- a/docs/Beck.Docs/Content/docs/reference/icons.md +++ b/docs/Beck.Docs/Content/docs/reference/icons.md @@ -1,7 +1,7 @@ --- title: Icons description: Every built-in icon key, rendered live from the engine's own registry. -order: 43 +order: 45 sectionLabel: Reference uid: docs.reference.icons --- diff --git a/docs/Beck.Docs/Content/docs/reference/styles.md b/docs/Beck.Docs/Content/docs/reference/styles.md index 3d38343..8feedb1 100644 --- a/docs/Beck.Docs/Content/docs/reference/styles.md +++ b/docs/Beck.Docs/Content/docs/reference/styles.md @@ -1,7 +1,7 @@ --- title: Style system description: The BeckStyle record and its sub-records, the built-in registry, style resolution and precedence, the metrics-font and artwork vocabularies, and the custom-style sanitizer. -order: 44 +order: 46 sectionLabel: Reference uid: docs.reference.styles --- diff --git a/docs/Beck.Docs/Content/docs/reference/yaml.md b/docs/Beck.Docs/Content/docs/reference/yaml.md index 07db933..15f1c45 100644 --- a/docs/Beck.Docs/Content/docs/reference/yaml.md +++ b/docs/Beck.Docs/Content/docs/reference/yaml.md @@ -1,7 +1,7 @@ --- title: YAML schema -description: Every key, value, and default in a Beck document — all four diagram types. -order: 40 +description: Every key, value, and default in a Beck document — all six diagram types. +order: 42 sectionLabel: Reference uid: docs.reference.yaml --- @@ -17,6 +17,8 @@ in before the diagram is laid out. | `sequence` | participants, lifelines, and ordered messages | `meta` `participants` `messages` `flow` | | `state` | a state machine of pills and transitions | `meta` `states` `transitions` `flow` | | `class` | UML class cards and relations | `meta` `classes` `relations` `groups` `flow` | +| `flowchart` | a decision/process graph | `meta` `steps` `links` `flow` | +| `mindmap` | a nested topic tree | `meta` `root` `topics` `flow` | The `flow` block has its own page: [Flow & animation](/docs/reference/flow). For a visual tour of these constructs, see the [syntax cheatsheet](/syntax). @@ -85,6 +87,8 @@ A list of nodes. Each needs a unique `id`; everything else is optional. | `variant` | `solid` `subtle` `ghost` | per kind | Visual weight: `subtle` is dimmed; `ghost` is dashed and transparent. | | `icon` | icon key or inline `` | per kind | A [named icon](#icons) or raw SVG markup. An unknown key falls back to the kind's icon. | | `status` | string | — | Status-pill text. | +| `items` | list of strings | — | Bulleted list rendered inside the card. | +| `body` | string | — | Wrapped paragraph rendered under the title (and items, if any). | | `accent` | token or CSS colour | per kind | A [colour token](#colours-and-theme-tokens) or a raw colour. | | `href` | string | — | Renders the card as a link (``). | | `target` | string | — | Anchor target (e.g. `_blank`); only meaningful with `href`. | @@ -265,6 +269,82 @@ Parents rank above children automatically (`inherits`/`implements` are flipped i hierarchy reads top-down). Class diagrams are structural, so they don't animate by default: without a `flow:` the diagram renders a still frame. Script a `flow:` if you want a guided tour. +## steps and links (`type: flowchart`) + +A decision/process graph on the layered engine. Steps referenced only by a link (never declared +under `steps:`) are auto-created as plain `process` cards, so a terse flowchart needs nothing but +`links:`. The token `"[*]"` (quote it — YAML) is the start/end pseudo-step: use it as a `from` for +the start terminator, as a `to` for the end terminator. See the [flowchart guide](/docs/guides/flowchart). + +`steps` entries (all optional refinements): + +| key | type | default | description | +|---|---|---|---| +| `id` | string | — | **Required.** Unique identifier. | +| `text` | string | = `id` | Display title. | +| `kind` | `process` `decision` `terminator` `io` `start` `end` | `process` | Shape: `process` → card, `decision` → diamond, `terminator` → pill, `io` → parallelogram, `start`/`end` → the start/end pseudo-shape. | +| `subtitle` | string | — | Muted second line. | +| `accent` | token or CSS colour | `neutral` | Step accent — uniform across every `kind`. | +| `icon` | icon key or inline `` | — | Same icon vocabulary as [architecture nodes](#icons). | +| `href`, `target` | string | — | Link the step. | +| `surface`, `textColor` | CSS colour | theme | Same one-off overrides as architecture nodes. | +| `width`, `rank`, `order` | number | auto | Same as architecture nodes. | + +`links` entries: + +| key | type | default | description | +|---|---|---|---| +| `from`, `to` | step id or `"[*]"` | — | **Required.** | +| `label` | string | — | Drawn on the line — e.g. a decision branch's `yes`/`no`. | +| `style` | `solid` `dashed` | `solid` | Line style. | +| `color` | token or CSS colour | edge | Stroke colour. | +| `note` | string | — | Narration caption for this link, shown just before its packet in the derived flow. See [narration](/docs/reference/flow#narration). | + +## root and topics (`type: mindmap`) + +A nested topic tree drawn as a two-sided "butterfly": a central `root`, first-level branches split +left/right, subtrees fanning outward. `meta.direction` is accepted but ignored — the layout is +fixed. See the [mind map guide](/docs/guides/mindmap). + +`root` — the centre topic. Either a plain string (shorthand for `title`) or a mapping: + +| key | type | default | description | +|---|---|---|---| +| `title` | string | — | **Required** (or use the plain-string shorthand). | +| `id` | string | auto | Explicit id; defaults to an engine-assigned path-derived id. | +| `subtitle` | string | — | Muted second line. | +| `items` | list of strings | — | Bulleted list rendered inside the card. | +| `body` | string | — | Wrapped paragraph rendered under the title/items. | +| `accent` | token or CSS colour | `primary` | Root accent; flows to first-level branches that don't set their own. | +| `icon` | icon key or inline `` | — | Same icon vocabulary as [architecture nodes](#icons). | +| `href`, `target`, `surface`, `textColor`, `width` | — | — | Same as architecture nodes. | + +`topics` — the first-level branches, and (via `children`) every deeper topic. Same fields as +`root`, plus: + +| key | type | default | description | +|---|---|---|---| +| `children` | list of topics | — | Nested topics, to any depth. Each is a string or a mapping with the same fields. | +| `status` | string | — | A semantic status pill on a rank-1 card; the colour follows the word (see below). | +| `variant` / `ghost` | `ghost` / `true` | — | Mark a not-yet-real branch: it and its whole subtree render neutral, dashed, and shadowless with a faint `planned` label. | + +**Depth roles.** Shape and size follow depth: the root (210×68) and every rank-1 branch (190×56) are +cards; from rank 2 outward a heading is a light pill. A topic with `items`/`body` stays a card at any +depth. Icons appear only on the root and rank-1 cards. + +**Accent cycling and inheritance.** The root resolves to `primary`. Each first-level branch takes the +next token from the cycle `info`, `primary`, `success`, `warn`, `danger` (wrapping; `neutral` is +reserved for ghost branches). Every descendant inherits its parent's *resolved* accent unless it +authors `accent:` explicitly — which then flows to its own children. Edges are undirected parent → +child curves with no arrowhead, in a muted blend of the child's accent and the edge colour, fanning +from a single point on each parent. + +**Status colours.** `complete`/`done` → success · `in progress` → warn · `blocked` → danger · +`review` → info · `planned` → neutral · anything else → the branch accent. + +A mind map renders **static** — no packets or narration, identical to the reduced-motion frame. A +`flow:` is accepted for forward-compatibility but is not animated. + ## Icons Set a node's `icon` to one of these named keys. Many keys are aliases that share a glyph. An unknown diff --git a/docs/Beck.Docs/Content/for-ai.llms.md b/docs/Beck.Docs/Content/for-ai.llms.md index ca294d2..b8a9d08 100644 --- a/docs/Beck.Docs/Content/for-ai.llms.md +++ b/docs/Beck.Docs/Content/for-ai.llms.md @@ -45,8 +45,8 @@ That is a complete diagram. With no `flow:` block, Beck derives a packet animati ## The document shape A Beck document is a YAML mapping that **always opens with a root `type:`** — one of -`architecture`, `sequence`, `state`, or `class`. The type picks the top-level keys; `meta` and -`flow` are shared by all four. For `type: architecture`: +`architecture`, `sequence`, `state`, `class`, `flowchart`, or `mindmap`. The type picks the +top-level keys; `meta` and `flow` are shared by all six. For `type: architecture`: ```yaml type: architecture # REQUIRED first — architecture | sequence | state | class @@ -59,7 +59,8 @@ flow: { ... } # optional — scripted animation; omit and one is derived The other types swap the middle keys: `type: sequence` uses `participants` + `messages`, `type: state` uses `states` + `transitions`, `type: class` uses `classes` + `relations` (+ -`groups`). Their cheat-sheets are [below](#the-other-diagram-types-sequence-state-class). +`groups`), `type: flowchart` uses `steps` + `links`, `type: mindmap` uses `root` + `topics`. Their +cheat-sheets are [below](#the-other-diagram-types-sequence-state-class-flowchart-mindmap). Inline (`{ }` / `[ ]`) and block YAML are both fine. Use whichever is clearer. @@ -84,8 +85,9 @@ field tables and defaults, see the [YAML schema reference](/docs/reference/yaml) ### nodes — `id` required, rest optional -Key fields: `id`, `title` (= `id`), `subtitle`, `kind`, `variant`, `icon`, `status`, `accent`, -`href`/`target`, `surface`/`textColor`, `width`, `rank`, `order`, `group`. +Key fields: `id`, `title` (= `id`), `subtitle`, `kind`, `variant`, `icon`, `status`, `items` (bulleted +list), `body` (wrapped paragraph), `accent`, `href`/`target`, `surface`/`textColor`, `width`, +`rank`, `order`, `group`. `kind` is shorthand that sets the default **icon + accent + variant** at once; override any of them individually. @@ -152,11 +154,12 @@ For anything else, pass raw inline `` using `fill="currentColor"`/ and a `0 0 24 24` viewBox so it inherits the node's accent and theme. Full live catalogue: [Icons reference](/docs/reference/icons). -## The other diagram types: sequence, state, class +## The other diagram types: sequence, state, class, flowchart, mindmap -All three share `meta`, `flow`, colours, and theming with architecture diagrams. Only the middle +All five share `meta`, `flow`, colours, and theming with architecture diagrams. Only the middle keys differ. Full tables: [YAML schema](/docs/reference/yaml); guides: -[sequence](/docs/guides/sequence) · [state](/docs/guides/state) · [class](/docs/guides/class). +[sequence](/docs/guides/sequence) · [state](/docs/guides/state) · [class](/docs/guides/class) · +[flowchart](/docs/guides/flowchart) · [mindmap](/docs/guides/mindmap). ### `type: sequence` — participants + messages @@ -233,6 +236,56 @@ relations: - { from: order, to: line, kind: composition, fromCard: "1", toCard: "*" } ``` +### `type: flowchart` — steps + links + +A decision/process graph on the layered engine. `steps:` is optional refinement (`id`, `text`, +`kind`, `subtitle`, `accent`, `icon`, `href`/`target`, `surface`/`textColor`, `width`, `rank`, +`order`) — ids used only in `links` are auto-created as `process` cards. `kind` is one of `process` +(card, default), `decision` (diamond), `terminator` (pill), `io` (parallelogram), `start`/`end` (the +start/end pseudo-shape). `"[*]"` (quoted!) works as a `from`/`to` shorthand for the start/end +pseudo-step, exactly like state diagrams. Link fields: `from`, `to`, `label`, `style`, `color`, +`note` (narrates the link in the derived flow). With no `flow:`, one packet rides each link in +declared order. + +```beck +type: flowchart +steps: + - { id: check, text: Valid?, kind: decision } +links: + - { from: "[*]", to: check } + - { from: check, to: charge, label: "yes" } + - { from: check, to: retry, label: "no" } + - { from: retry, to: check } + - { from: charge, to: "[*]" } +``` + +### `type: mindmap` — root + topics + +A nested topic tree drawn as a two-sided butterfly (root centred, branches fanning left/right). +`root` is required — a plain string (its title) or a mapping (`title`, `id`, `subtitle`, `items`, +`body`, `accent`, `icon`, `href`/`target`, `surface`/`textColor`, `width`). `topics:` are the +first-level branches; each can nest further via its own `children:`, to any depth, with the same +fields as `root`. A heading-only leaf renders as a pill; anything with `items`/`body` or `children` +renders as a card. First-level branches cycle through `primary`, `info`, `success`, `warn`, +`danger`, `neutral`; deeper topics inherit their parent's resolved accent unless they set their own. +`meta.direction` is accepted but ignored — the layout is always the fixed butterfly. With no +`flow:`, packets broadcast from the root out to the leaves. + +```beck +type: mindmap +root: Beck +topics: + - title: Rendering + children: + - title: Pipeline + items: [Model, Text, Layout] + - title: Determinism + body: Same YAML, same SVG. + - title: Packages + accent: success + children: [Beck, Beck.Skia] +``` + ## Flow (animation) A `flow` is an ordered list of single-key step mappings the engine compiles into a timeline and @@ -334,10 +387,12 @@ message, or transition to caption a derived flow. Each diagram type has its own builder: `DiagramBuilder` (architecture), `SequenceDiagramBuilder` (`Participant`/`Message`/`Reply`/`Section`), `StateDiagramBuilder` -(`State`/`Transition`/`Initial`/`Final`), and `ClassDiagramBuilder` +(`State`/`Transition`/`Initial`/`Final`), `ClassDiagramBuilder` (`Class`/`Inherits`/`Composition`/…, plus **`ClassDiagramBuilder.FromTypes(typeof(…), …)`**, which reflects real CLR types into an always-current class diagram — base types become `inherits`, -interfaces `implements`, property types labelled associations). +interfaces `implements`, property types labelled associations), `FlowchartDiagramBuilder` +(`Step`/`Process`/`Decision`/`Terminator`/`Io`/`Start`/`End`/`Link`), and `MindMapDiagramBuilder` +(`Root`/`Topic`, with `Topic` nestable to any depth for `children`). C# enums map to schema tokens (lowercased), with one special case: `EdgeCurve.StepRound` → `step-round`. `Direction` stays uppercase. Enums available: `Direction`, `NodeKind`, `NodeVariant`, @@ -383,6 +438,9 @@ or [Add Beck to a Pennington site](/docs/guides/pennington). structural reference material and renders a still frame unless you script a `flow:` yourself. `meta.loop: false` plays once; `meta.animate: false` (or the reader's reduced-motion setting) renders a static frame. +- **`type: mindmap` ignores `meta.direction`** — the butterfly layout is always fixed left/right. +- **Flowchart `"[*]"`** works as a `links` `from`/`to` shorthand for the start/end pseudo-step, + exactly like state diagrams' entry/exit pseudo-state — quote it, since `[*]` is YAML syntax. - **Flow steps are ordered**, single-key mappings; `parallel` runs its children simultaneously; `status`/`working`/`stream`/`activate` persist until cleared or `reset`. - **It's plain YAML** — the parser reports friendly errors (with a line number for syntax issues), diff --git a/docs/Beck.Docs/wwwroot/examples/guides/flowchart-01.beck.yaml b/docs/Beck.Docs/wwwroot/examples/guides/flowchart-01.beck.yaml new file mode 100644 index 0000000..0f1d5d7 --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/guides/flowchart-01.beck.yaml @@ -0,0 +1,8 @@ +type: flowchart +steps: + - { id: begin, kind: start } + - { id: process, text: Process order } + - { id: finish, kind: end } +links: + - { from: "[*]", to: process } + - { from: process, to: "[*]" } diff --git a/docs/Beck.Docs/wwwroot/examples/guides/flowchart-02.beck.yaml b/docs/Beck.Docs/wwwroot/examples/guides/flowchart-02.beck.yaml new file mode 100644 index 0000000..a9cf635 --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/guides/flowchart-02.beck.yaml @@ -0,0 +1,16 @@ +type: flowchart +meta: { title: Validate Input } +steps: + - { id: read, text: Read request, kind: io } + - { id: check, text: Valid?, kind: decision } + - { id: process, text: Process request } + - { id: retry, text: Fix input } + - { id: reject, text: Give up, kind: terminator } +links: + - { from: "[*]", to: read } + - { from: read, to: check } + - { from: check, to: process, label: "yes" } + - { from: check, to: retry, label: "no" } + - { from: retry, to: check } + - { from: retry, to: reject, label: "give up" } + - { from: process, to: "[*]" } diff --git a/docs/Beck.Docs/wwwroot/examples/guides/flowchart-03.beck.yaml b/docs/Beck.Docs/wwwroot/examples/guides/flowchart-03.beck.yaml new file mode 100644 index 0000000..ff5fb90 --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/guides/flowchart-03.beck.yaml @@ -0,0 +1,10 @@ +type: flowchart +meta: { direction: LR } +steps: + - { id: submit, text: Submit, kind: terminator, accent: primary } + - { id: charge, text: Charge card } + - { id: fail, text: Payment failed, accent: danger } +links: + - { from: submit, to: charge, note: "The order is submitted for payment." } + - { from: charge, to: fail, label: declined, style: dashed, color: danger, note: "A declined card routes back for a retry." } + - { from: fail, to: charge, label: retry } diff --git a/docs/Beck.Docs/wwwroot/examples/guides/mindmap-01.beck.yaml b/docs/Beck.Docs/wwwroot/examples/guides/mindmap-01.beck.yaml new file mode 100644 index 0000000..61d3a81 --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/guides/mindmap-01.beck.yaml @@ -0,0 +1,11 @@ +type: mindmap +root: Beck +topics: + - title: Rendering + icon: code + - title: Diagram Types + icon: chart + - title: Packages + icon: container + - title: Theming + icon: browser diff --git a/docs/Beck.Docs/wwwroot/examples/guides/mindmap-02.beck.yaml b/docs/Beck.Docs/wwwroot/examples/guides/mindmap-02.beck.yaml new file mode 100644 index 0000000..0254bb0 --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/guides/mindmap-02.beck.yaml @@ -0,0 +1,28 @@ +type: mindmap +meta: { title: Beck Architecture } +root: + title: Beck + subtitle: server-side diagrams +topics: + - title: Rendering + children: + - title: Pipeline + items: [Model, Text, Layout, Route, Svg, Animate] + - title: Determinism + body: > + Same YAML and the same options always produce + byte-identical SVG. + - title: Packages + accent: success + children: + - title: Beck + items: [engine, authoring] + - title: Beck.Skia + children: + - title: HarfBuzz shaping + - title: Font metrics + - title: Theming + accent: danger + children: + - title: CSS variables + - title: Dark mode diff --git a/docs/Beck.Docs/wwwroot/examples/guides/nodes-10.beck.yaml b/docs/Beck.Docs/wwwroot/examples/guides/nodes-10.beck.yaml new file mode 100644 index 0000000..bd6e179 --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/guides/nodes-10.beck.yaml @@ -0,0 +1,7 @@ +type: architecture +meta: { direction: LR } +nodes: + - { id: orders, title: Orders Service, items: ["POST /orders", "GET /orders/:id"] } + - { id: notes, title: Release Notes, body: "Ships weekly on Thursdays; see the changelog for details." } +edges: + - { from: orders, to: notes } diff --git a/docs/Beck.Docs/wwwroot/examples/mindmap-beck.beck.yaml b/docs/Beck.Docs/wwwroot/examples/mindmap-beck.beck.yaml new file mode 100644 index 0000000..83f30ba --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/mindmap-beck.beck.yaml @@ -0,0 +1,42 @@ +type: mindmap +meta: + title: How Beck works +root: + title: Beck + subtitle: server-side diagrams + icon: model +topics: + - title: Pipeline + icon: code + children: + - title: Model + body: YAML becomes a fully-defaulted DiagramModel — nothing downstream sees raw input. + - title: Layout + items: [rank, order, coords] + - title: Route + items: [orthogonal, step-round, obstacle avoidance] + - title: Animate + body: The flow script compiles to shared-cycle CSS keyframes — no client JavaScript. + - title: Diagram types + icon: chart + children: + - title: Architecture + - title: Sequence + - title: State + - title: Class + - title: Mindmap + - title: Packages + icon: container + children: + - title: Beck + items: [engine, authoring] + - title: Beck.Skia + children: + - title: HarfBuzz shaping + - title: Font metrics + - title: Theming + icon: browser + children: + - title: CSS variables + - title: Dark mode + - title: Named styles diff --git a/docs/Beck.Docs/wwwroot/examples/mindmap-roadmap.beck.yaml b/docs/Beck.Docs/wwwroot/examples/mindmap-roadmap.beck.yaml new file mode 100644 index 0000000..29a72f3 --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/mindmap-roadmap.beck.yaml @@ -0,0 +1,35 @@ +type: mindmap +meta: + title: Platform Redesign +root: + title: Platform Redesign + subtitle: Q3 initiative + icon: model +topics: + - title: Research + icon: search + status: complete + children: + - title: User interviews + - title: Analytics audit + - title: Competitive teardown + - title: Design + icon: browser + status: in progress + children: + - title: Design tokens + - title: Component library + - title: Prototype + - title: Engineering + icon: code + children: + - title: API migration + - title: Frontend rebuild + - title: Perf budget + - title: Launch + icon: cloud + ghost: true + children: + - title: Beta program + - title: Marketing site + - title: Docs refresh diff --git a/docs/Beck.Docs/wwwroot/examples/mindmap-skills.beck.yaml b/docs/Beck.Docs/wwwroot/examples/mindmap-skills.beck.yaml new file mode 100644 index 0000000..38bbdac --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/mindmap-skills.beck.yaml @@ -0,0 +1,47 @@ +type: mindmap +meta: + title: Frontend Roadmap +root: + title: Frontend + subtitle: a learning path + icon: browser +topics: + - title: Internet + icon: globe + status: complete + children: + - title: How the web works + - title: HTTP + - title: DNS & hosting + - title: HTML & CSS + icon: code + status: complete + children: + - title: Semantic HTML + - title: Flexbox & Grid + - title: Responsive design + - title: JavaScript + icon: bolt + status: in progress + children: + - title: Syntax & types + - title: The DOM + - title: Fetch & promises + - title: Tooling + icon: terminal + children: + - title: Git + - title: Package managers + - title: Bundlers + - title: Frameworks + icon: container + children: + - title: React + - title: State management + - title: Advanced + icon: chart + ghost: true + children: + - title: Testing + - title: Performance + - title: Accessibility diff --git a/src/Beck.Sample/Program.cs b/src/Beck.Sample/Program.cs index ade3c54..74dfb16 100644 --- a/src/Beck.Sample/Program.cs +++ b/src/Beck.Sample/Program.cs @@ -3,7 +3,7 @@ // Emits Beck diagrams from code — the general "generate from any model" pattern. // Prints raw YAML (so it can be validated/rendered). Pass an argument to pick a -// sample: architecture (default) | sequence | state | class | reflection. +// sample: architecture (default) | sequence | state | class | flowchart | mindmap | reflection. var which = args.Length > 0 ? args[0] : "architecture"; if (which == "sequence") @@ -57,6 +57,45 @@ return; } +if (which == "flowchart") +{ + Console.WriteLine(new FlowchartDiagramBuilder("Checkout") + .Direction(Direction.Tb) + .Io("read", "Read cart") + .Decision("valid", "Payment valid?") + .Process("charge", "Charge card") + .Process("retry", "Fix payment") + .Terminator("reject", "Give up") + .Link(FlowchartDiagramBuilder.Pseudo, "read") + .Link("read", "valid") + .Link("valid", "charge", "yes") + .Link("valid", "retry", "no") + .Link("retry", "valid") + .Link("retry", "reject", "give up") + .Link("charge", FlowchartDiagramBuilder.Pseudo) + .ToYaml()); + return; +} + +if (which == "mindmap") +{ + Console.WriteLine(new MindMapDiagramBuilder("Beck") + .Root("Beck", r => r.Subtitle("server-side diagrams")) + .Topic("Rendering", t => t + .Accent(AccentToken.Info) + .Topic("Pipeline", p => p.Items("Model", "Text", "Layout", "Route", "Svg", "Animate")) + .Topic("Determinism", d => d.Body("Same YAML, same SVG."))) + .Topic("Packages", t => t + .Accent(AccentToken.Success) + .Topic("Beck", b => b.Items("engine", "authoring")) + .Topic("Beck.Skia")) + .Topic("Theming", t => t + .Topic("CSS variables") + .Topic("Dark mode")) + .ToYaml()); + return; +} + if (which == "reflection") { // The always-current-domain-model pattern: reflect real CLR types into a diff --git a/src/Beck/Authoring/BeckSchema.cs b/src/Beck/Authoring/BeckSchema.cs index 255fb39..c051e41 100644 --- a/src/Beck/Authoring/BeckSchema.cs +++ b/src/Beck/Authoring/BeckSchema.cs @@ -50,7 +50,7 @@ public static class BeckSchema // ---- per-section key lists (curated against Model/Schema.cs) ------------- /// Top-level document keys. - public static IReadOnlyList TopKeys { get; } = ["type", "meta", "nodes", "edges", "groups", "flow"]; + public static IReadOnlyList TopKeys { get; } = ["type", "meta", "nodes", "edges", "groups", "flow", "steps", "links", "root", "topics"]; /// meta: keys. public static IReadOnlyList MetaKeys { get; } = ["title", "subtitle", "style", "direction", "theme", "animate", "loop", "fit", "spacing", "narrate", ]; @@ -62,6 +62,16 @@ public static class BeckSchema ]; /// Group entry keys. public static IReadOnlyList GroupKeys { get; } = ["id", "label", "members", "accent"]; + /// Flowchart steps: entry keys. + public static IReadOnlyList StepKeys { get; } = ["id", "text", "kind", "subtitle", "icon", "accent", "href", "target", "surface", "textColor", "width", "rank", "order", + ]; + /// Flowchart links: entry keys. + public static IReadOnlyList LinkKeys { get; } = ["from", "to", "label", "style", "color", "note"]; + /// Flowchart step kind: values. + public static IReadOnlyList StepKinds { get; } = ["process", "decision", "terminator", "io", "start", "end"]; + /// Mind-map root: / topics: entry keys (a topic may also be a plain title string). + public static IReadOnlyList TopicKeys { get; } = ["id", "title", "subtitle", "accent", "icon", "items", "body", "children", "href", "target", "surface", "textColor", "width", + ]; /// Fields whose value is a declared node/group id — completed from the document. public static IReadOnlyList IdValuedFields { get; } = ["from", "to", "via", "members", "node", "group"]; @@ -86,7 +96,7 @@ public static class BeckSchema "accent" or "color" => Accents, "fromSide" or "toSide" => Sides, "animate" or "loop" => Bool, - "kind" => section == "edges" ? EdgeKinds : Kinds, + "kind" => section switch { "edges" => EdgeKinds, "steps" => StepKinds, _ => Kinds }, _ => null, }; @@ -95,12 +105,19 @@ public static class BeckSchema public static IReadOnlyDictionary Docs { get; } = new Dictionary { // sections - ["type"] = "Root diagram type: `architecture`, `sequence`, `state` or `class`.", + ["type"] = "Root diagram type: `architecture`, `sequence`, `state`, `class`, `flowchart` or `mindmap`.", ["meta"] = "Diagram-wide settings — title, direction, theme, animation.", ["nodes"] = "The boxes in your diagram. Each needs an `id`; `title`/`kind`/`icon` are optional.", ["edges"] = "Connections between nodes. `from` + `to` are required.", ["groups"] = "Boundaries that wrap members (nodes or nested groups) in a labelled box.", ["flow"] = "A scripted animation: packets, highlights and status changes over time.", + ["steps"] = "Flowchart steps. Each needs an `id`; `text`/`kind` are optional.", + ["links"] = "Connections between flowchart steps. `from` + `to` are required.", + ["root"] = "The centre topic of a mind map (a mapping, or a plain title string).", + ["topics"] = "First-level mind-map branches, split left/right around the root.", + ["children"] = "Nested child topics under this topic (arbitrary depth).", + ["items"] = "Bulleted list rendered on the topic/node card.", + ["body"] = "Wrapped paragraph rendered on the topic/node card.", // meta fields ["title"] = "Display name on the card / diagram heading.", ["subtitle"] = "Secondary line under the title.", @@ -146,6 +163,15 @@ public static class BeckSchema ["sequence"] = "Participants exchanging messages over time.", ["state"] = "States and transitions.", ["class"] = "Classes with compartments and UML relations.", + ["flowchart"] = "Process/decision flow: steps and links.", + ["mindmap"] = "A nested topic tree drawn as a two-sided butterfly around a central root.", + ["text"] = "Step display text — defaults to the step `id`.", + ["process"] = "A rectangular process step (default).", + ["decision"] = "A diamond branch point.", + ["terminator"] = "A pill-shaped terminator step.", + ["io"] = "A parallelogram input/output step.", + ["start"] = "A start terminator step.", + ["end"] = "An end terminator step.", ["service"] = "A generic service box.", ["db"] = "A database (cylinder).", ["queue"] = "A message queue.", diff --git a/src/Beck/Authoring/Enums.cs b/src/Beck/Authoring/Enums.cs index 76a7340..d360147 100644 --- a/src/Beck/Authoring/Enums.cs +++ b/src/Beck/Authoring/Enums.cs @@ -173,6 +173,23 @@ public enum ArrowEnds Both, } +/// The shape a type: flowchart step renders as. +public enum StepKind +{ + /// A rectangular action step — the default. Renders as a card. + Process, + /// A branch point. Renders as a diamond. + Decision, + /// A pipeline entry/exit. Renders as a pill. + Terminator, + /// An input/output step. Renders as a parallelogram. + Io, + /// The flow's start pseudo-step. + Start, + /// The flow's end pseudo-step. + End, +} + /// How two classes in a type: class diagram relate. public enum RelationKind { @@ -192,7 +209,7 @@ public enum RelationKind internal static class Tokens { - public static string Of(Direction d) => d.ToString(); + public static string Of(Direction d) => d.ToString().ToUpperInvariant(); public static string Of(ThemeMode t) => t.ToString().ToLowerInvariant(); public static string Of(FitMode f) => f.ToString().ToLowerInvariant(); public static string Of(NodeKind k) => k.ToString().ToLowerInvariant(); @@ -205,6 +222,7 @@ internal static class Tokens public static string Of(Side s) => s.ToString().ToLowerInvariant(); public static string Of(ArrowEnds a) => a.ToString().ToLowerInvariant(); public static string Of(RelationKind r) => r.ToString().ToLowerInvariant(); + public static string Of(StepKind k) => k.ToString().ToLowerInvariant(); public static string Of(EdgeCurve c) => c switch { diff --git a/src/Beck/Authoring/FlowchartBuilder.cs b/src/Beck/Authoring/FlowchartBuilder.cs new file mode 100644 index 0000000..ccbf7f6 --- /dev/null +++ b/src/Beck/Authoring/FlowchartBuilder.cs @@ -0,0 +1,403 @@ +using System.Globalization; +using System.Text; + +namespace Beck.Authoring; + +/// +/// Builds a type: flowchart Beck diagram — a decision/process graph of +/// steps and labelled links. Links auto-create the steps they mention (as plain +/// cards), so is only +/// needed to refine a step's title, kind, or accent; and +/// declare terminator pseudo-steps, and the "[*]" pseudo-step (see +/// ) works from a link exactly like 's +/// entry/exit pseudo-state. +/// +/// +/// +/// var fence = new FlowchartDiagramBuilder("Checkout") +/// .Direction(Direction.Tb) +/// .Decision("valid", "Payment valid?") +/// .Link(FlowchartDiagramBuilder.Pseudo, "valid") +/// .Link("valid", "charge", "yes") +/// .Link("valid", "retry", "no") +/// .Link("charge", FlowchartDiagramBuilder.Pseudo) +/// .Link("retry", "valid") +/// .ToFence(); +/// +/// +public sealed class FlowchartDiagramBuilder +{ + /// The start/end pseudo-step token (usable directly in ). + public const string Pseudo = "[*]"; + + private readonly List _steps = new(); + private readonly List _links = new(); + private readonly MetaOptions _meta = new(); + private FlowBuilder? _flow; + + /// Create an empty flowchart. + public FlowchartDiagramBuilder() { } + + /// Create a flowchart with a title. + public FlowchartDiagramBuilder(string title) => _meta._title = title; + + /// Set the diagram title. + public FlowchartDiagramBuilder Title(string title) { _meta._title = title; return this; } + + /// Set the diagram subtitle. + public FlowchartDiagramBuilder Subtitle(string subtitle) { _meta._subtitle = subtitle; return this; } + + /// Set the visual style by its meta.style token (e.g. "classic"). + public FlowchartDiagramBuilder Style(string name) { _meta._style = name; return this; } + + /// Set the visual style from a (emits its ). + public FlowchartDiagramBuilder Style(BeckStyle style) { _meta._style = style.Name; return this; } + + /// Set the layout direction — (default) reads like a procedure, like a pipeline. + public FlowchartDiagramBuilder Direction(Direction direction) { _meta._direction = direction; return this; } + + /// Set the theme: (default), , or . + public FlowchartDiagramBuilder Theme(ThemeMode theme) { _meta._theme = theme; return this; } + + /// Enable or disable the flow animation. + public FlowchartDiagramBuilder Animate(bool animate) { _meta._animate = animate; return this; } + + /// Loop the flow (default) or play it through once. + public FlowchartDiagramBuilder Loop(bool loop) { _meta._loop = loop; return this; } + + /// How the diagram behaves when wider than its container. + public FlowchartDiagramBuilder Fit(FitMode fit) { _meta._fit = fit; return this; } + + /// Toggle + tune the narration caption. Captions come from a link + /// (or explicit steps); + /// the knobs pace each caption's on-screen time by its length. + public FlowchartDiagramBuilder Narrate(bool enabled = true, int? wpm = null, double? min = null, double? pad = null) + { + _meta._narrate = enabled; + if (wpm is { } w) + { + _meta._narrateWpm = w; + } + + if (min is { } m) + { + _meta._narrateMin = m; + } + + if (pad is { } p) + { + _meta._narratePad = p; + } + + return this; + } + + /// Tune layout spacing: rank gap (along the flow), node gap (across), and corner radius (px). + public FlowchartDiagramBuilder Spacing(int? rank = null, int? node = null, int? cornerRadius = null) + { + if (rank is { } r) + { + _meta._spacingRank = r; + } + + if (node is { } n) + { + _meta._spacingNode = n; + } + + if (cornerRadius is { } c) + { + _meta._spacingCornerRadius = c; + } + + return this; + } + + /// Declare a step and refine it via — title, kind, accent, subtitle. Undeclared steps still render as plain process cards. + public FlowchartDiagramBuilder Step(string id, Action? configure = null) + { + var s = new StepBuilder(id); + configure?.Invoke(s); + _steps.Add(s); + return this; + } + + /// Declare a step (a rectangular action card). + public FlowchartDiagramBuilder Process(string id, string? text = null) => Step(id, StepKind.Process, text); + + /// Declare a step (a diamond branch point). + public FlowchartDiagramBuilder Decision(string id, string? text = null) => Step(id, StepKind.Decision, text); + + /// Declare a step (a pipeline entry/exit pill). + public FlowchartDiagramBuilder Terminator(string id, string? text = null) => Step(id, StepKind.Terminator, text); + + /// Declare an step (a parallelogram). + public FlowchartDiagramBuilder Io(string id, string? text = null) => Step(id, StepKind.Io, text); + + /// Declare a pseudo-step (defaults its id to "start"). + public FlowchartDiagramBuilder Start(string? id = null) => Step(id ?? "start", StepKind.Start, null); + + /// Declare an pseudo-step (defaults its id to "end"). + public FlowchartDiagramBuilder End(string? id = null) => Step(id ?? "end", StepKind.End, null); + + private FlowchartDiagramBuilder Step(string id, StepKind kind, string? text) + { + var s = new StepBuilder(id).Kind(kind); + if (text != null) + { + s.Text(text); + } + + _steps.Add(s); + return this; + } + + /// Add a link (steps are auto-created as needed). Reference as + /// or to draw from/to the start or end pseudo-step. + public FlowchartDiagramBuilder Link(string from, string to, string? label = null, Action? configure = null) + { + var l = new LinkBuilder(from, to); + if (label != null) + { + l.Label(label); + } + + configure?.Invoke(l); + _links.Add(l.ToFlow()); + return this; + } + + /// Script the animation explicitly. Without this the engine walks the links in declared order. + public FlowchartDiagramBuilder Flow(Action configure) + { + _flow ??= new FlowBuilder(); + configure(_flow); + return this; + } + + /// Render the diagram as Beck YAML. + /// The diagram has no steps and no links. + public string ToYaml() + { + if (_steps.Count == 0 && _links.Count == 0) + { + throw new InvalidOperationException("A flowchart needs at least one Step()/Process()/Decision()/... or Link()."); + } + + var sb = new StringBuilder(); + sb.Append("type: flowchart\n"); + _meta.AppendYaml(sb); + if (_steps.Count > 0) + { + sb.Append("steps:\n"); + foreach (var s in _steps) + { + sb.Append(" - ").Append(s.ToFlow()).Append('\n'); + } + } + if (_links.Count > 0) + { + sb.Append("links:\n"); + foreach (var l in _links) + { + sb.Append(" - ").Append(l).Append('\n'); + } + } + _flow?.AppendYaml(sb); + return sb.ToString(); + } + + /// Render as a fenced ```beck Markdown block — drop it into any Markdown page and it renders to a static SVG. + public string ToFence() => BeckMarkdown.Fence(ToYaml()); + + /// + public override string ToString() => ToYaml(); +} + +/// +/// Refines a single step inside a Step(id, s => …) callback. Steps a +/// link mentions but you never declare keep their auto-created process card. +/// +public sealed class StepBuilder +{ + private readonly string _id; + private string? _text; + private StepKind? _kind; + private string? _subtitle; + private string? _icon; + private string? _accent; + private string? _href; + private string? _target; + private string? _surface; + private string? _textColor; + private int? _width; + private int? _rank; + private int? _order; + + internal StepBuilder(string id) => _id = id; + + /// Set the display title (defaults to the id). + public StepBuilder Text(string text) { _text = text; return this; } + + /// Set the step's shape: (default), , , , , or . + public StepBuilder Kind(StepKind kind) { _kind = kind; return this; } + + /// Set the muted subtitle line. + public StepBuilder Subtitle(string subtitle) { _subtitle = subtitle; return this; } + + /// Set a named icon key or raw inline <svg> markup. + public StepBuilder Icon(string icon) { _icon = icon; return this; } + + /// Set the accent to a semantic token (follows the theme). + public StepBuilder Accent(AccentToken token) { _accent = Tokens.Of(token); return this; } + + /// Set the accent to a raw CSS color. + public StepBuilder Accent(string color) { _accent = color; return this; } + + /// Make the step a link. Pass target: "_blank" to open in a new tab. + public StepBuilder Link(string href, string? target = null) { _href = href; _target = target; return this; } + + /// Override the card background (a raw CSS color). + public StepBuilder Surface(string color) { _surface = color; return this; } + + /// Override the card text color (a raw CSS color). + public StepBuilder TextColor(string color) { _textColor = color; return this; } + + /// Fix the step width in pixels. + public StepBuilder Width(int px) { _width = px; return this; } + + /// Force the step into a specific layout rank. + public StepBuilder Rank(int rank) { _rank = rank; return this; } + + /// Tie-break order within the rank. + public StepBuilder Order(int order) { _order = order; return this; } + + internal string ToFlow() + { + var pairs = new List<(string, string)> { ("id", YamlWriter.Scalar(_id)) }; + if (_text != null) + { + pairs.Add(("text", YamlWriter.Scalar(_text))); + } + + if (_kind is { } k) + { + pairs.Add(("kind", Tokens.Of(k))); + } + + if (_subtitle != null) + { + pairs.Add(("subtitle", YamlWriter.Scalar(_subtitle))); + } + + if (_icon != null) + { + pairs.Add(("icon", YamlWriter.Scalar(_icon))); + } + + if (_accent != null) + { + pairs.Add(("accent", YamlWriter.Scalar(_accent))); + } + + if (_href != null) + { + pairs.Add(("href", YamlWriter.Scalar(_href))); + } + + if (_target != null) + { + pairs.Add(("target", YamlWriter.Scalar(_target))); + } + + if (_surface != null) + { + pairs.Add(("surface", YamlWriter.Scalar(_surface))); + } + + if (_textColor != null) + { + pairs.Add(("textColor", YamlWriter.Scalar(_textColor))); + } + + if (_width is { } w) + { + pairs.Add(("width", w.ToString(CultureInfo.InvariantCulture))); + } + + if (_rank is { } r) + { + pairs.Add(("rank", r.ToString(CultureInfo.InvariantCulture))); + } + + if (_order is { } o) + { + pairs.Add(("order", o.ToString(CultureInfo.InvariantCulture))); + } + + return YamlWriter.FlowMap(pairs); + } +} + +/// Configures one link inside a Link(from, to, label, l => …) callback. +public sealed class LinkBuilder +{ + private readonly string _from; + private readonly string _to; + private string? _label; + private string? _color; + private string? _note; + private EdgeStyle? _style; + + internal LinkBuilder(string from, string to) + { + _from = from; + _to = to; + } + + /// Set the link label (e.g. a decision branch's "yes"/"no"). + public LinkBuilder Label(string label) { _label = label; return this; } + + /// Override the line style: or . + public LinkBuilder Style(EdgeStyle style) { _style = style; return this; } + + /// Set the stroke to a semantic token (follows the theme). + public LinkBuilder Color(AccentToken token) { _color = Tokens.Of(token); return this; } + + /// Set the stroke to a raw CSS color. + public LinkBuilder Color(string color) { _color = color; return this; } + + /// Narrate this link: the text becomes a caption just before the + /// link fires in an auto-derived flow (ignored when a Flow is scripted). + public LinkBuilder Note(string note) { _note = note; return this; } + + internal string ToFlow() + { + var pairs = new List<(string, string)> + { + ("from", YamlWriter.Scalar(_from)), + ("to", YamlWriter.Scalar(_to)), + }; + if (_label != null) + { + pairs.Add(("label", YamlWriter.Scalar(_label))); + } + + if (_style is { } s) + { + pairs.Add(("style", Tokens.Of(s))); + } + + if (_color != null) + { + pairs.Add(("color", YamlWriter.Scalar(_color))); + } + + if (_note != null) + { + pairs.Add(("note", YamlWriter.Scalar(_note))); + } + + return YamlWriter.FlowMap(pairs); + } +} diff --git a/src/Beck/Authoring/MindMapBuilder.cs b/src/Beck/Authoring/MindMapBuilder.cs new file mode 100644 index 0000000..569b5fc --- /dev/null +++ b/src/Beck/Authoring/MindMapBuilder.cs @@ -0,0 +1,329 @@ +using System.Globalization; +using System.Text; + +namespace Beck.Authoring; + +/// +/// Builds a type: mindmap Beck diagram — a nested topic tree drawn as a +/// two-sided "butterfly" (root centred, branches fanning left/right). +/// declares the centre topic; +/// (on this builder, or nested on a ) declares a +/// first-level branch or a deeper child — arbitrary nesting is supported. +/// +/// +/// +/// var fence = new MindMapDiagramBuilder("Beck") +/// .Root("Beck") +/// .Topic("Rendering", t => t +/// .Accent(AccentToken.Info) +/// .Topic("Pipeline", p => p.Items("Model", "Text", "Layout")) +/// .Topic("Determinism", d => d.Body("Same YAML, same SVG."))) +/// .Topic("Packages", t => t +/// .Topic("Beck") +/// .Topic("Beck.Skia")) +/// .ToFence(); +/// +/// +public sealed class MindMapDiagramBuilder +{ + private readonly MetaOptions _meta = new(); + private TopicBuilder? _root; + private readonly List _topics = new(); + private FlowBuilder? _flow; + + /// Create an empty mindmap. + public MindMapDiagramBuilder() { } + + /// Create a mindmap with a diagram title. + public MindMapDiagramBuilder(string title) => _meta._title = title; + + /// Set the diagram title. + public MindMapDiagramBuilder Title(string title) { _meta._title = title; return this; } + + /// Set the diagram subtitle. + public MindMapDiagramBuilder Subtitle(string subtitle) { _meta._subtitle = subtitle; return this; } + + /// Set the visual style by its meta.style token (e.g. "classic"). + public MindMapDiagramBuilder Style(string name) { _meta._style = name; return this; } + + /// Set the visual style from a (emits its ). + public MindMapDiagramBuilder Style(BeckStyle style) { _meta._style = style.Name; return this; } + + /// Set the theme: (default), , or . + public MindMapDiagramBuilder Theme(ThemeMode theme) { _meta._theme = theme; return this; } + + /// Enable or disable the flow animation. + public MindMapDiagramBuilder Animate(bool animate) { _meta._animate = animate; return this; } + + /// Loop the flow (default) or play it through once. + public MindMapDiagramBuilder Loop(bool loop) { _meta._loop = loop; return this; } + + /// How the diagram behaves when wider than its container. + public MindMapDiagramBuilder Fit(FitMode fit) { _meta._fit = fit; return this; } + + /// Toggle + tune the narration caption. Captions come from explicit + /// steps; the knobs pace each caption's on-screen time by its length. + public MindMapDiagramBuilder Narrate(bool enabled = true, int? wpm = null, double? min = null, double? pad = null) + { + _meta._narrate = enabled; + if (wpm is { } w) + { + _meta._narrateWpm = w; + } + + if (min is { } m) + { + _meta._narrateMin = m; + } + + if (pad is { } p) + { + _meta._narratePad = p; + } + + return this; + } + + /// Tune layout spacing: rank gap (along the flow), node gap (across), and corner radius (px). + public MindMapDiagramBuilder Spacing(int? rank = null, int? node = null, int? cornerRadius = null) + { + if (rank is { } r) + { + _meta._spacingRank = r; + } + + if (node is { } n) + { + _meta._spacingNode = n; + } + + if (cornerRadius is { } c) + { + _meta._spacingCornerRadius = c; + } + + return this; + } + + /// Declare the centre topic by title. + public MindMapDiagramBuilder Root(string title) => Root(title, null); + + /// Declare the centre topic and refine it via . + public MindMapDiagramBuilder Root(string title, Action? configure) + { + var t = new TopicBuilder(title); + configure?.Invoke(t); + _root = t; + return this; + } + + /// Declare a first-level branch by title. + public MindMapDiagramBuilder Topic(string title) => Topic(title, null); + + /// Declare a first-level branch and refine it (and its nested children) via . + public MindMapDiagramBuilder Topic(string title, Action? configure) + { + var t = new TopicBuilder(title); + configure?.Invoke(t); + _topics.Add(t); + return this; + } + + /// Script the animation explicitly. Without this the engine derives packets root → leaves. + public MindMapDiagramBuilder Flow(Action configure) + { + _flow ??= new FlowBuilder(); + configure(_flow); + return this; + } + + /// Render the diagram as Beck YAML. + /// The diagram has no topic. + public string ToYaml() + { + if (_root == null) + { + throw new InvalidOperationException("A mindmap needs a Root(...) topic."); + } + + var sb = new StringBuilder(); + sb.Append("type: mindmap\n"); + _meta.AppendYaml(sb); + sb.Append("root: ").Append(_root.ToFlow()).Append('\n'); + if (_topics.Count > 0) + { + sb.Append("topics:\n"); + foreach (var t in _topics) + { + sb.Append(" - ").Append(t.ToFlow()).Append('\n'); + } + } + + _flow?.AppendYaml(sb); + return sb.ToString(); + } + + /// Render as a fenced ```beck Markdown block — drop it into any Markdown page and it renders to a static SVG. + public string ToFence() => BeckMarkdown.Fence(ToYaml()); + + /// + public override string ToString() => ToYaml(); +} + +/// +/// Configures a single topic inside a Root(title, t => …) or +/// Topic(title, t => …) callback. Call again +/// on the same builder to nest children — arbitrary depth is supported, and +/// each level emits as a nested flow-style YAML mapping (still parses as plain +/// YAML; the block-style form in the schema doc is just one equivalent spelling). +/// +public sealed class TopicBuilder +{ + private readonly string _title; + private string? _id; + private string? _subtitle; + private readonly List _items = new(); + private string? _body; + private string? _status; + private bool _ghost; + private string? _accent; + private string? _icon; + private string? _href; + private string? _target; + private string? _surface; + private string? _textColor; + private int? _width; + private readonly List _children = new(); + + internal TopicBuilder(string title) => _title = title; + + /// Set an explicit id (defaults to the path-derived id the engine assigns). + public TopicBuilder Id(string id) { _id = id; return this; } + + /// Set the muted subtitle line. + public TopicBuilder Subtitle(string subtitle) { _subtitle = subtitle; return this; } + + /// Add one bullet to the topic card's item list. + public TopicBuilder Item(string item) { _items.Add(item); return this; } + + /// Add several bullets to the topic card's item list at once. + public TopicBuilder Items(params string[] items) { _items.AddRange(items); return this; } + + /// Set the wrapped body paragraph rendered under the items. + public TopicBuilder Body(string body) { _body = body; return this; } + + /// Set the topic's status — a semantic pill on a rank-1 card (or inline after a leaf). + public TopicBuilder Status(string status) { _status = status; return this; } + + /// Mark this branch (and its whole subtree) a ghost: neutral, dashed, shadowless "planned". + public TopicBuilder Ghost(bool ghost = true) { _ghost = ghost; return this; } + + /// Set the accent to a semantic token (follows the theme); flows to descendants unless they override it. + public TopicBuilder Accent(AccentToken token) { _accent = Tokens.Of(token); return this; } + + /// Set the accent to a raw CSS color; flows to descendants unless they override it. + public TopicBuilder Accent(string color) { _accent = color; return this; } + + /// Set a named icon key or raw inline <svg> markup. + public TopicBuilder Icon(string icon) { _icon = icon; return this; } + + /// Make the topic a link. Pass target: "_blank" to open in a new tab. + public TopicBuilder Link(string href, string? target = null) { _href = href; _target = target; return this; } + + /// Override the card background (a raw CSS color). + public TopicBuilder Surface(string color) { _surface = color; return this; } + + /// Override the card text color (a raw CSS color). + public TopicBuilder TextColor(string color) { _textColor = color; return this; } + + /// Fix the card width in pixels. + public TopicBuilder Width(int px) { _width = px; return this; } + + /// Declare a child topic by title. + public TopicBuilder Topic(string title) => Topic(title, null); + + /// Declare a child topic and refine it (and its own nested children) via . + public TopicBuilder Topic(string title, Action? configure) + { + var t = new TopicBuilder(title); + configure?.Invoke(t); + _children.Add(t); + return this; + } + + internal string ToFlow() + { + var pairs = new List<(string, string)> { ("title", YamlWriter.Scalar(_title)) }; + if (_id != null) + { + pairs.Add(("id", YamlWriter.Scalar(_id))); + } + + if (_subtitle != null) + { + pairs.Add(("subtitle", YamlWriter.Scalar(_subtitle))); + } + + if (_items.Count > 0) + { + pairs.Add(("items", YamlWriter.FlowSeq(_items.Select(YamlWriter.Scalar)))); + } + + if (_body != null) + { + pairs.Add(("body", YamlWriter.Scalar(_body))); + } + + if (_status != null) + { + pairs.Add(("status", YamlWriter.Scalar(_status))); + } + + if (_ghost) + { + pairs.Add(("ghost", "true")); + } + + if (_accent != null) + { + pairs.Add(("accent", YamlWriter.Scalar(_accent))); + } + + if (_icon != null) + { + pairs.Add(("icon", YamlWriter.Scalar(_icon))); + } + + if (_href != null) + { + pairs.Add(("href", YamlWriter.Scalar(_href))); + } + + if (_target != null) + { + pairs.Add(("target", YamlWriter.Scalar(_target))); + } + + if (_surface != null) + { + pairs.Add(("surface", YamlWriter.Scalar(_surface))); + } + + if (_textColor != null) + { + pairs.Add(("textColor", YamlWriter.Scalar(_textColor))); + } + + if (_width is { } w) + { + pairs.Add(("width", w.ToString(CultureInfo.InvariantCulture))); + } + + if (_children.Count > 0) + { + pairs.Add(("children", YamlWriter.FlowSeq(_children.Select(c => c.ToFlow())))); + } + + return YamlWriter.FlowMap(pairs); + } +} diff --git a/src/Beck/Layout/LayeredLayout.cs b/src/Beck/Layout/LayeredLayout.cs index b58da6e..37a5c31 100644 --- a/src/Beck/Layout/LayeredLayout.cs +++ b/src/Beck/Layout/LayeredLayout.cs @@ -21,8 +21,8 @@ internal static class LayeredLayout private const double CanvasPad = 16; private const double LaneReserve = 22, LabelReserveGap = 10, SelfLoopReserve = 30; - private sealed record LayItem(string Id, double W, double H, double? Rank, double? Order); - private sealed record LayerResult(Dictionary Rects, double Width, double Height); + internal sealed record LayItem(string Id, double W, double H, double? Rank, double? Order); + internal sealed record LayerResult(Dictionary Rects, double Width, double Height); private sealed record Composed(Dictionary NodeRects, Dictionary GroupRects, double Width, double Height); public static LayoutResult Compute(DiagramModel model, IReadOnlyDictionary sizes) @@ -282,7 +282,7 @@ private static double BackEdgeGutter(DiagramModel model, Dictionary 0 ? Math.Max(0, Math.Ceiling(need - CanvasPad)) : 0; } - private static LayerResult LayoutLayer( + internal static LayerResult LayoutLayer( List items, List<(string F, string T)> edges, Direction dir, double gap, double rankGap) { var horizontal = dir is Direction.Lr or Direction.Rl; diff --git a/src/Beck/Layout/MindMapLayout.cs b/src/Beck/Layout/MindMapLayout.cs new file mode 100644 index 0000000..bfaaffb --- /dev/null +++ b/src/Beck/Layout/MindMapLayout.cs @@ -0,0 +1,161 @@ +using Beck.Model; + +namespace Beck.Layout; + +/// +/// The two-sided "butterfly" layout for type: mindmap. A central root, its first-level +/// branches partitioned left/right, and each half laid out with the shared single-level engine +/// () — the right half flowing , +/// the left half (a mirror) — then unified so both halves' root rects +/// coincide on one central node. +/// +/// Partition. Each first-level branch owns a subtree; its weight is the subtree's total +/// node height. Branches are assigned by longest-processing-time (heaviest first, declaration order +/// breaking ties) to whichever side is currently lighter, ties going right — so a lone branch lands +/// on the right and the two halves stay balanced. +/// +/// Compose. The root is included in BOTH half-layouts (as the rank-0 anchor), so rank +/// spacing is identical on each side. The left half is translated so its root rect lands exactly on +/// the right half's root rect; everything is then shifted into positive space with the same +/// CanvasPad the layered engine uses. No coordinate is ever negative. +/// +internal static class MindMapLayout +{ + private static readonly Size _fallback = new(180, 64); + private const double CanvasPad = 16; + + internal static LayoutResult Compute(DiagramModel model, IReadOnlyDictionary sizes) + { + var gap = model.Meta.Spacing.Node; + var rankGap = model.Meta.Spacing.Rank; + Size SizeOf(string id) => sizes.GetValueOrDefault(id, _fallback); + + // 1. Root = the rank-0 topic (exactly one; fall back to declaration order defensively). + var rootNode = model.Nodes.FirstOrDefault(n => n.Rank == 0) ?? model.Nodes[0]; + var rootId = rootNode.Id; + + // 2. parent → children adjacency (edges are parent→child in declaration order). + var childrenOf = new Dictionary>(); + foreach (var e in model.Edges) + { + (childrenOf.TryGetValue(e.From, out var kids) ? kids : childrenOf[e.From] = new()).Add(e.To); + } + + List Subtree(string branch) + { + var acc = new List(); + var seen = new HashSet(); + void Go(string x) + { + if (!seen.Add(x)) + { + return; + } + + acc.Add(x); + foreach (var c in childrenOf.GetValueOrDefault(x) ?? []) + { + Go(c); + } + } + Go(branch); + return acc; + } + + var branches = childrenOf.GetValueOrDefault(rootId) ?? []; + var subtrees = branches.ToDictionary(b => b, Subtree); + + // 3. Balance branches left/right by subtree weight (total node height), heaviest first. + var indexed = branches + .Select((b, i) => (Branch: b, Index: i, Weight: subtrees[b].Sum(id => SizeOf(id).H))) + .OrderByDescending(x => x.Weight) + .ThenBy(x => x.Index) + .ToList(); + + double leftW = 0, rightW = 0; + var leftBranches = new List(); + var rightBranches = new List(); + foreach (var x in indexed) + { + if (rightW <= leftW) // ties → right (so a single branch lands right) + { + rightBranches.Add(x.Branch); + rightW += x.Weight; + } + else + { + leftBranches.Add(x.Branch); + leftW += x.Weight; + } + } + + // 4. Each half = root + its branches' subtrees. + HashSet HalfSet(List halfBranches) + { + var set = new HashSet { rootId }; + foreach (var b in halfBranches) + { + foreach (var id in subtrees[b]) + { + set.Add(id); + } + } + + return set; + } + + var rightSet = HalfSet(rightBranches); + var leftSet = HalfSet(leftBranches); + + List ItemsFor(HashSet set) => model.Nodes + .Where(n => set.Contains(n.Id)) + .Select(n => { var s = SizeOf(n.Id); return new LayeredLayout.LayItem(n.Id, s.W, s.H, n.Rank, n.Order); }) + .ToList(); + + List<(string, string)> EdgesFor(HashSet set) => model.Edges + .Where(e => set.Contains(e.From) && set.Contains(e.To)) + .Select(e => (e.From, e.To)) + .ToList(); + + // 5. Lay out each half — right flows LR, left flows RL (root at the half's inner edge either way). + var right = LayeredLayout.LayoutLayer(ItemsFor(rightSet), EdgesFor(rightSet), Direction.Lr, gap, rankGap); + var left = LayeredLayout.LayoutLayer(ItemsFor(leftSet), EdgesFor(leftSet), Direction.Rl, gap, rankGap); + + // 6. Unify: translate the left half so its root rect lands exactly on the right half's root. + var rightRoot = right.Rects[rootId]; + var leftRoot = left.Rects[rootId]; + double lox = rightRoot.X - leftRoot.X, loy = rightRoot.Y - leftRoot.Y; + + var placed = new Dictionary(); + foreach (var (id, r) in right.Rects) + { + placed[id] = r; + } + + foreach (var (id, r) in left.Rects) + { + if (id == rootId) + { + continue; // the shared root is already placed by the right half (coincident) + } + + placed[id] = r.Offset(lox, loy); + } + + // 7. Shift everything into positive space with a consistent canvas pad. Never negative. + var minX = placed.Values.Min(r => r.X); + var minY = placed.Values.Min(r => r.Y); + double dx = CanvasPad - minX, dy = CanvasPad - minY; + + var nodes = new Dictionary(); + foreach (var (id, r) in placed) + { + nodes[id] = r.Offset(dx, dy); + } + + var width = Math.Ceiling(nodes.Values.Max(r => r.X + r.W) + CanvasPad); + var height = Math.Ceiling(nodes.Values.Max(r => r.Y + r.H) + CanvasPad); + + return new LayoutResult(nodes, new Dictionary(), width, height); + } +} diff --git a/src/Beck/Model/Defaults.cs b/src/Beck/Model/Defaults.cs index d529c04..166ab5c 100644 --- a/src/Beck/Model/Defaults.cs +++ b/src/Beck/Model/Defaults.cs @@ -22,6 +22,11 @@ internal static class Defaults [DiagramType.Sequence] = DefaultSpacing, [DiagramType.State] = new(Rank: 130, Node: 72, CornerRadius: 16), [DiagramType.Class] = new(Rank: 130, Node: 72, CornerRadius: 16), + [DiagramType.Flowchart] = new(Rank: 130, Node: 72, CornerRadius: 16), + // Mind maps read best tight (design handoff "Branch accents"): Node 20 gives a leaf-row pitch + // of 50 (30px pill + 20 gap), and Rank 70 is the face-to-face gap the fixed branch cubics bow + // across (controls at ±40 out of the root / ±35 out of rank 1). + [DiagramType.MindMap] = new(Rank: 70, Node: 20, CornerRadius: 16), }; /// Narration is available by default; wpm/min/pad set the reading-time pace. diff --git a/src/Beck/Model/FlowchartBuilder.cs b/src/Beck/Model/FlowchartBuilder.cs new file mode 100644 index 0000000..d57c700 --- /dev/null +++ b/src/Beck/Model/FlowchartBuilder.cs @@ -0,0 +1,229 @@ +using static Beck.Model.Coerce; + +namespace Beck.Model; + +/// +/// type: flowchart — a decision/process graph on the layered engine. +/// +/// +/// type: flowchart +/// meta: { title: ..., direction: TB, ... } # shared meta (see Validate.BuildMeta) +/// steps: +/// - id: start # required, unique +/// text: Start # title; defaults to id +/// kind: start # process | decision | terminator | io | start | end (default: process) +/// subtitle: ... # optional +/// accent: primary|success|warn|danger|info|neutral +/// href: ... +/// target: ... +/// surface: ... +/// textColor: ... +/// width: 200 +/// rank: 0 +/// order: 0 +/// icon: ... +/// links: +/// - from: start # required; also accepts the "[*]" pseudo-step +/// to: validate # required +/// label: yes # e.g. a decision branch label +/// style: solid|dashed +/// color: ... +/// note: ... +/// flow: ... # optional authored flow; else derived from links +/// +/// +/// Kind → shape: process → Card, decision → Diamond, terminator → Pill, +/// io → Parallelogram, start/end → the Start/End pseudo-shape (also reachable +/// by referencing the literal "[*]" id from a link, exactly like 's +/// entry/exit pseudo-state). Steps referenced only by a link (never declared under steps:) are +/// auto-materialized as process cards. Default accent is for +/// every kind (kept uniform with rather than special-casing decisions). +/// +/// "[*]" binding. Unlike a state diagram (where the entry/exit dot has no +/// declarable counterpart), a flowchart can declare its own kind: start / kind: end +/// steps. So "[*]" binds to a declared step rather than always spawning an anonymous dot: +/// a "[*]" in a link's from resolves to the single declared kind: start step +/// (and in to, the single declared kind: end) when exactly one exists — the author +/// gets the node they named, not a stray dot beside it. With zero declared starts (ends) +/// the anonymous #start (#end) pseudo-node is materialized as before. With two or +/// more, "[*]" is ambiguous and throws: reference the specific step id instead. +/// +internal static class FlowchartBuilder +{ + private const string Pseudo = "[*]"; + private const string StartId = "#start"; + private const string EndId = "#end"; + + private static NodeShape ShapeFor(string kind) => kind switch + { + "process" => NodeShape.Card, + "decision" => NodeShape.Diamond, + "terminator" => NodeShape.Pill, + "io" => NodeShape.Parallelogram, + "start" or "end" => NodeShape.Start, // overwritten below to Start/End as appropriate + _ => throw new BeckYamlException( + $"`kind` must be one of: process, decision, terminator, io, start, end (got \"{kind}\")"), + }; + + private static NodeModel Step(IReadOnlyDictionary s) + { + var id = AsString(s.GetValueOrDefault("id"), "step.id"); + if (id is Pseudo or StartId or EndId) + { + throw new BeckYamlException($"\"{id}\" is reserved — reference the start/end pseudo-step from a link instead"); + } + + var kind = OneOfString(s.GetValueOrDefault("kind"), ["process", "decision", "terminator", "io", "start", "end"], $"step \"{id}\" kind", "process"); + var shape = kind switch + { + "start" => NodeShape.Start, + "end" => NodeShape.End, + _ => ShapeFor(kind), + }; + + return new NodeModel + { + Id = id, + Title = OptString(s.GetValueOrDefault("text")) ?? id, + Subtitle = OptString(s.GetValueOrDefault("subtitle")), + Icon = OptString(s.GetValueOrDefault("icon")), + Kind = NodeKind.Service, + Variant = NodeVariant.Solid, + Accent = Colors.AccentToCss(OptString(s.GetValueOrDefault("accent")), AccentToken.Neutral), + Href = OptString(s.GetValueOrDefault("href")), + Target = OptString(s.GetValueOrDefault("target")), + Surface = OptString(s.GetValueOrDefault("surface")), + TextColor = OptString(s.GetValueOrDefault("textColor")), + Width = OptNumber(s.GetValueOrDefault("width"), $"step \"{id}\" width"), + Rank = OptNumber(s.GetValueOrDefault("rank"), $"step \"{id}\" rank"), + Order = OptNumber(s.GetValueOrDefault("order"), $"step \"{id}\" order"), + Shape = shape, + Items = [], + Fields = [], + Methods = [], + }; + } + + private static NodeModel PseudoNode(string id) => new() + { + Id = id, + Title = "", + Kind = NodeKind.Service, + Variant = NodeVariant.Solid, + Accent = "var(--beck-text)", + Shape = id == StartId ? NodeShape.Start : NodeShape.End, + Items = [], + Fields = [], + Methods = [], + }; + + public static DiagramModel Build(IReadOnlyDictionary root) + { + var meta = Validate.BuildMeta(AsObject(root.GetValueOrDefault("meta"), "meta"), DiagramType.Flowchart); + + // Declared steps collected first, pushed in first-reference order. + var declared = new Dictionary(); + foreach (var rs in AsArray(root.GetValueOrDefault("steps"), "steps")) + { + var n = Step(AsObject(rs, "step")); + if (!declared.TryAdd(n.Id, n)) + { + throw new BeckYamlException($"Duplicate step id \"{n.Id}\""); + } + } + + var nodes = new List(); + var byId = new Dictionary(); + void Add(NodeModel n) { byId[n.Id] = n; nodes.Add(n); } + + // "[*]" prefers a declared start/end step over the anonymous pseudo-node; keyed by context. + var declaredStarts = declared.Values.Where(n => n.Shape == NodeShape.Start).ToList(); + var declaredEnds = declared.Values.Where(n => n.Shape == NodeShape.End).ToList(); + + string Ensure(string id, string ctx) + { + if (id == Pseudo) + { + var candidates = ctx == "from" ? declaredStarts : declaredEnds; + var kindName = ctx == "from" ? "start" : "end"; + if (candidates.Count == 1) + { + // Exactly one declared start/end: bind "[*]" to it — no floating pseudo-dot. + var bound = candidates[0]; + if (!byId.ContainsKey(bound.Id)) + { + Add(bound); + } + + return bound.Id; + } + if (candidates.Count >= 2) + { + throw new BeckYamlException( + $"\"[*]\" is ambiguous — {candidates.Count} steps declare `kind: {kindName}` " + + $"({string.Join(", ", candidates.Select(c => $"\"{c.Id}\""))}); " + + $"reference a specific step id instead of \"[*]\""); + } + + var pid = ctx == "from" ? StartId : EndId; + if (!byId.ContainsKey(pid)) + { + Add(PseudoNode(pid)); + } + + return pid; + } + if (!byId.ContainsKey(id)) + { + Add(declared.GetValueOrDefault(id) ?? Step(new Dictionary { ["id"] = id })); + } + + return id; + } + + var edges = new List(); + foreach (var rl in AsArray(root.GetValueOrDefault("links"), "links")) + { + var l = AsObject(rl, "link"); + var from = Ensure(AsString(l.GetValueOrDefault("from"), "link.from"), "from"); + var to = Ensure(AsString(l.GetValueOrDefault("to"), "link.to"), "to"); + edges.Add(new EdgeModel + { + Id = $"{from}->{to}#{edges.Count}", + From = from, + To = to, + Label = OptString(l.GetValueOrDefault("label")), + Style = OneOf(l.GetValueOrDefault("style"), Tokens.EdgeStyle, "link.style", EdgeStyle.Solid), + Curve = EdgeCurve.StepRound, + Kind = EdgeKind.Control, + Color = OptColor(l.GetValueOrDefault("color")) ?? Defaults.EdgeColor, + Arrow = ArrowEnds.End, + Note = OptString(l.GetValueOrDefault("note")), + Reply = false, + }); + } + // Declared steps never referenced by a link still render. + foreach (var (id, n) in declared) + { + if (!byId.ContainsKey(id)) + { + Add(n); + } + } + + if (nodes.Count == 0) + { + throw new BeckYamlException("A flowchart needs at least one entry under `steps` or `links`"); + } + + var flow = root.GetValueOrDefault("flow") != null + ? Validate.BuildFlow(AsObject(root["flow"], "flow"), [..byId.Keys], []) + : Defaults.DeriveFlow(nodes, edges); + if (!meta.Loop) + { + flow.Repeat = 0; + } + + return new DiagramModel { Meta = meta, Nodes = nodes, Groups = [], Edges = edges, Flow = flow, Sections = [] }; + } +} diff --git a/src/Beck/Model/MindMapBuilder.cs b/src/Beck/Model/MindMapBuilder.cs new file mode 100644 index 0000000..642271d --- /dev/null +++ b/src/Beck/Model/MindMapBuilder.cs @@ -0,0 +1,206 @@ +using static Beck.Model.Coerce; + +namespace Beck.Model; + +/// +/// type: mindmap — a nested topic tree drawn as a two-sided "butterfly": a central root, +/// first-level branches split left/right, subtrees fanning outward. Compiles onto the layered +/// engine (each half is a single-level layout; see +/// ). +/// +/// +/// type: mindmap +/// meta: { title: ..., ... } # shared meta (direction is IGNORED — the layout is fixed LR) +/// root: # the centre topic +/// title: Beck # or `root: Beck` shorthand (a plain string is the title) +/// # optional: id, subtitle, accent, icon, items, body, href, target, surface, textColor, width +/// topics: # the first-level branches +/// - title: Rendering # heading only +/// accent: info # optional; else cycled per first-level branch +/// children: # arbitrary nesting depth +/// - title: Pipeline +/// items: [Model, Text, Layout] # bulleted card +/// - title: Determinism +/// body: > # wrapped-paragraph card +/// Same YAML, same SVG. +/// - title: Packages +/// children: [ ... ] +/// flow: ... # optional authored flow; else derived (packets root → leaves) +/// +/// +/// Shape mapping. The root is always a (the centrepiece). +/// Any topic carrying items/body is a Card. A heading-only topic that HAS children is a +/// Card too — it anchors a subtree, so it reads as a sub-root with visible weight. A heading-only +/// LEAF topic is a (light, terminal). +/// +/// Accent cycling + inheritance. The root resolves to . +/// Each first-level branch takes the next token from the fixed cycle +/// [Primary, Info, Success, Warn, Danger, Neutral] (wrapping). Every descendant inherits its +/// parent's RESOLVED accent unless it authors accent: explicitly, which then flows to ITS +/// children. All values are CSS var(--beck-*) tokens (or a passed-through raw colour) — no +/// theme or stylesheet changes. +/// +/// Edges are parent → child, coloured by the child's resolved accent so each branch reads as +/// one continuous coloured thread; they are curves with no arrowhead (a +/// mind map is undirected reading). +/// +internal static class MindMapBuilder +{ + /// First-level branch accent cycle (design handoff "Branch accents"): each branch takes the + /// next token, wrapping. Neutral is deliberately excluded — it is reserved for ghost branches. + private static readonly AccentToken[] _cycle = + [AccentToken.Info, AccentToken.Primary, AccentToken.Success, AccentToken.Warn, AccentToken.Danger]; + + /// A topic is a mapping, or a plain string that is shorthand for its title. + private static (string? Title, IReadOnlyDictionary Map) AsTopic(object? raw) + { + if (raw is IReadOnlyDictionary d) + { + return (OptString(d.GetValueOrDefault("title")), d); + } + + var s = OptString(raw); + if (s != null) + { + return (s, AsObject(null, "topic")); + } + + throw new BeckYamlException("A mindmap topic must be a string or a mapping"); + } + + public static DiagramModel Build(IReadOnlyDictionary root) + { + var meta = Validate.BuildMeta(AsObject(root.GetValueOrDefault("meta"), "meta"), DiagramType.MindMap); + // Direction is fixed: the butterfly is always laid out horizontally, and the router keys off + // Meta.Direction (LR ⇒ primary-horizontal) to pick left/right node faces via AutoSides. + meta = meta with { Direction = Direction.Lr }; + + var rawRoot = root.GetValueOrDefault("root"); + if (rawRoot is null) + { + throw new BeckYamlException("A mindmap needs a `root:` topic"); + } + + var nodes = new List(); + var edges = new List(); + var ids = new HashSet(); + var order = 0; + + // Recursive tree walk → flat node/edge lists. Rank = depth (root 0); Order = stable traversal + // index. `childrenOverride` lets the root pull its first-level branches from top-level `topics:` + // while every deeper node pulls from its own `children:`. + void Walk(object? raw, int depth, string path, int branchIndex, string? parentId, string parentAccent, + bool parentGhost, IReadOnlyList? childrenOverride) + { + var (title, map) = AsTopic(raw); + var authoredId = OptString(map.GetValueOrDefault("id")); + var id = authoredId ?? path; + + var children = childrenOverride ?? AsArray(map.GetValueOrDefault("children"), $"topic \"{id}\" children"); + var items = StringList(map.GetValueOrDefault("items"), $"topic \"{id}\" items"); + var body = OptString(map.GetValueOrDefault("body")); + var hasContent = items.Count > 0 || body != null; + + // Depth roles (handoff): root + rank-1 are always cards; rank 2+ is a label-only pill + // UNLESS it authors items/body (content-aware — then it stays an accent-tinted card). + var shape = depth <= 1 || hasContent ? NodeShape.Card : NodeShape.Pill; + + // Ghost branch (handoff): `variant: ghost` / `ghost: true` marks a not-yet-real branch; it and + // its whole subtree render neutral + dashed + shadowless. The flag inherits like the accent. + var ghost = parentGhost + || string.Equals(OptString(map.GetValueOrDefault("variant")), "ghost", StringComparison.OrdinalIgnoreCase) + || OptBool(map.GetValueOrDefault("ghost"), $"topic \"{id}\" ghost", false); + + var authoredAccent = OptString(map.GetValueOrDefault("accent")); + var accent = ghost + ? Colors.AccentToCss(null, AccentToken.Neutral) + : depth switch + { + 0 => Colors.AccentToCss(authoredAccent, AccentToken.Primary), + 1 => Colors.AccentToCss(authoredAccent, _cycle[branchIndex % _cycle.Length]), + // Deeper: an explicit accent overrides (and then flows on); else inherit the parent's. + _ => authoredAccent != null ? Colors.AccentToCss(authoredAccent, AccentToken.Neutral) : parentAccent, + }; + + // A known icon key or raw inline passes through; anything else is dropped. Icons appear + // only at the root and rank 1 (handoff depth roles) — deeper nodes never carry one. + var rawIcon = depth <= 1 ? OptString(map.GetValueOrDefault("icon")) : null; + var icon = rawIcon != null && (Svg.Icons.IsKnownIcon(rawIcon) || rawIcon.TrimStart().StartsWith('<')) + ? rawIcon + : null; + + if (!ids.Add(id)) + { + throw new BeckYamlException($"Duplicate topic id \"{id}\""); + } + + nodes.Add(new NodeModel + { + Id = id, + Title = title ?? id, + Subtitle = OptString(map.GetValueOrDefault("subtitle")), + Items = items, + Body = body, + Icon = icon, + Status = OptString(map.GetValueOrDefault("status")), + Kind = NodeKind.Service, + Variant = ghost ? NodeVariant.Ghost : NodeVariant.Solid, + Accent = accent, + Href = OptString(map.GetValueOrDefault("href")), + Target = OptString(map.GetValueOrDefault("target")), + Surface = OptString(map.GetValueOrDefault("surface")), + TextColor = OptString(map.GetValueOrDefault("textColor")), + Width = OptNumber(map.GetValueOrDefault("width"), $"topic \"{id}\" width"), + Rank = depth, + Order = order++, + Shape = shape, + Fields = [], + Methods = [], + }); + + if (parentId != null) + { + edges.Add(new EdgeModel + { + Id = $"{parentId}->{id}#{edges.Count}", + From = parentId, + To = id, + Curve = EdgeCurve.S, + Arrow = ArrowEnds.None, + // A ghost subtree's edges dash too (the child carries the ghost accent = neutral). + Style = ghost ? EdgeStyle.Dashed : EdgeStyle.Solid, + Kind = EdgeKind.Data, + // A muted branch thread (handoff): the child's accent blended 55% into the edge token, + // so the hierarchy reads as colour without competing with the nodes. + Color = $"color-mix(in srgb, {accent} 55%, var(--beck-edge))", + Reply = false, + }); + } + + for (var i = 0; i < children.Count; i++) + { + // First-level branch index is the child's position under the root; deeper nodes carry + // their branch's index through unchanged (accent cycling only reads it at depth 1). + Walk(children[i], depth + 1, $"{path}-{i}", depth == 0 ? i : branchIndex, id, accent, ghost, null); + } + } + + var topics = AsArray(root.GetValueOrDefault("topics"), "topics"); + Walk(rawRoot, 0, "root", 0, null, Colors.AccentToCss(null, AccentToken.Primary), false, topics); + + var flow = root.GetValueOrDefault("flow") != null + ? Validate.BuildFlow(AsObject(root["flow"], "flow"), [..ids], []) + : Defaults.DeriveFlow(nodes, edges); + if (!meta.Loop) + { + flow.Repeat = 0; + } + + // Mindmaps ship STATIC (handoff "Motion & determinism"): the render emits only the fully-revealed + // frame — no packets/trails/narration/node-bounce. Forcing the flag here funnels through the three + // animation gates in SvgRenderer, exactly as ClassBuilder does for a flow-less class diagram. + meta.Animate = false; + + return new DiagramModel { Meta = meta, Nodes = nodes, Groups = [], Edges = edges, Flow = flow, Sections = [] }; + } +} diff --git a/src/Beck/Model/Tokens.cs b/src/Beck/Model/Tokens.cs index 47d1bba..43e5d38 100644 --- a/src/Beck/Model/Tokens.cs +++ b/src/Beck/Model/Tokens.cs @@ -6,7 +6,7 @@ namespace Beck.Model; // downstream layout/route/render/animate stages switch exhaustively. /// What the diagram is; picks the layout + routing strategy. -internal enum DiagramType { Architecture, Sequence, State, Class } +internal enum DiagramType { Architecture, Sequence, State, Class, Flowchart, MindMap } /// Primary layout axis. internal enum Direction { Tb, Bt, Lr, Rl } @@ -77,7 +77,9 @@ internal static class Tokens (Model.DiagramType.Architecture, "architecture"), (Model.DiagramType.Sequence, "sequence"), (Model.DiagramType.State, "state"), - (Model.DiagramType.Class, "class")); + (Model.DiagramType.Class, "class"), + (Model.DiagramType.Flowchart, "flowchart"), + (Model.DiagramType.MindMap, "mindmap")); public static readonly TokenMap Direction = new( (Model.Direction.Tb, "TB"), diff --git a/src/Beck/Model/Validate.cs b/src/Beck/Model/Validate.cs index a434906..1489aaf 100644 --- a/src/Beck/Model/Validate.cs +++ b/src/Beck/Model/Validate.cs @@ -575,6 +575,8 @@ public static DiagramModel BuildModel(object? raw) DiagramType.Sequence => SequenceBuilder.Build(root), DiagramType.State => StateBuilder.Build(root), DiagramType.Class => ClassBuilder.Build(root), + DiagramType.Flowchart => FlowchartBuilder.Build(root), + DiagramType.MindMap => MindMapBuilder.Build(root), _ => BuildArchitectureModel(root), }; } diff --git a/src/Beck/Route/EdgePainter.cs b/src/Beck/Route/EdgePainter.cs index 3802357..be7214e 100644 --- a/src/Beck/Route/EdgePainter.cs +++ b/src/Beck/Route/EdgePainter.cs @@ -99,6 +99,21 @@ void Walk(string gid) prep[i] = new EdgePrep(from.Value, to.Value, obstacles, fs, ts); } + // A decision diamond's edges want DISTINCT vertices (the classic flowchart idiom): a bottom + // vertex shared by the yes- and no-branch reads as one line forking. Since diamonds are + // point-anchored (no spread), two edges resolving to the same face land on the identical + // point and share a trunk. Redistribute the laterally-displaced ones onto the Left/Right + // (or Top/Bottom) side vertices matching their far endpoint's direction. Flowchart-only so + // architecture/state/class diamond routing (and its goldens) stays byte-identical. + // Snapshot the pre-reassignment sides so a reassigned diamond edge that ends up cutting a node + // (the side-vertex corner route skips the obstacle avoidance the primary-face route has) can be + // reverted to its clean original route below. null when no reassignment runs. + var origPrep = model.Meta.Type == DiagramType.Flowchart ? (EdgePrep?[])prep.Clone() : null; + if (origPrep is not null) + { + ReassignDiamondSides(model, prep); + } + // Terminal dots (state [*] start/end, 16×16) are points, not faces — spreading // several edges across one just bends each into a tiny jog. Anchor them all at // centre so a lone incoming edge can run straight into the dot. @@ -119,6 +134,16 @@ void Walk(string gid) var paraSkew = model.Nodes .Where(n => n.Shape is NodeShape.Parallelogram && layout.Nodes.ContainsKey(n.Id)) .ToDictionary(n => n.Id, n => Svg.Artwork.ParallelogramSkew(layout.Nodes[n.Id].H) / 2); + // Mindmap: children fan from their parent's single face midpoint (suppress the anchor spread so + // siblings share one start point), and each parent→child edge uses a fixed cubic offset — ±40 out + // of the root, ±35 out of a rank-1+ node (keyed by the parent = edge.From). + Dictionary? mmOffset = null; + if (model.Meta.Type == DiagramType.MindMap) + { + pointNodes.UnionWith(model.Nodes.Select(n => n.Id)); + mmOffset = model.Nodes.ToDictionary(n => n.Id, n => (n.Rank ?? 0) == 0 ? 40.0 : 35.0); + } + var shifts = AnchorShifts(model.Edges, prep, pointNodes); var outEdges = new List(); @@ -139,15 +164,191 @@ void Walk(string gid) // NudgePerp no-ops Top/Bottom faces). var fromNudge = paraSkew.TryGetValue(edge.From, out var fn) ? fn : 0; var toNudge = paraSkew.TryGetValue(edge.To, out var tn) ? tn : 0; - var routed = OrthogonalRouter.RouteEdge(new RouteRequest( - p.From, p.To, p.FromSide, p.ToSide, edge.Curve, p.Obstacles, radius, primaryHorizontal, + var mmOff = mmOffset != null && mmOffset.TryGetValue(edge.From, out var mo) ? mo : 0; + RoutedPath Route(Side fromSide, Side toSide) => OrthogonalRouter.RouteEdge(new RouteRequest( + p.From, p.To, fromSide, toSide, edge.Curve, p.Obstacles, radius, primaryHorizontal, new Size(layout.Width, layout.Height), from.Shift, to.Shift, - fromFanned, toFanned, from.Lane, to.Lane, fromNudge, toNudge)); + fromFanned, toFanned, from.Lane, to.Lane, fromNudge, toNudge, mmOff)); + + var routed = Route(p.FromSide, p.ToSide); + // A diamond edge reassigned to a side vertex (Bug-2 fix) trades the primary-face route's + // obstacle avoidance for a naive corner; if that corner cuts a node, fall back to the + // original vertex — correctness (no through-node) over the aesthetic spread. + if (origPrep?[i] is { } op && (op.FromSide != p.FromSide || op.ToSide != p.ToSide) + && HitsAnyObstacle(routed.Points, p.Obstacles)) + { + routed = Route(op.FromSide, op.ToSide); + } outEdges.Add(new RoutedEdge(edge, routed.D, routed.Points)); } return outEdges; } + private static List<(int Idx, bool IsFrom)> Bucket( + Dictionary> map, string key) + { + if (!map.TryGetValue(key, out var list)) + { + map[key] = list = new(); + } + + return list; + } + + /// Does any segment of this polyline cut through an obstacle rect? Matches the + /// through-node test the clean-line monitor gates on (a slightly larger inset than the router's + /// own hit test, so a route that only grazes a stroke is not counted). + private static bool HitsAnyObstacle(IReadOnlyList pts, IReadOnlyList obstacles) + { + const double Inset = 4; + for (var i = 0; i < pts.Count - 1; i++) + { + Point a = pts[i], b = pts[i + 1]; + foreach (var r in obstacles) + { + double x1 = r.X + Inset, y1 = r.Y + Inset, x2 = r.X + r.W - Inset, y2 = r.Y + r.H - Inset; + if (x2 <= x1 || y2 <= y1) + { + continue; + } + + bool hit; + if (Math.Abs(a.Y - b.Y) < 0.5) + { + hit = a.Y > y1 && a.Y < y2 && Math.Max(a.X, b.X) > x1 && Math.Min(a.X, b.X) < x2; + } + else if (Math.Abs(a.X - b.X) < 0.5) + { + hit = a.X > x1 && a.X < x2 && Math.Max(a.Y, b.Y) > y1 && Math.Min(a.Y, b.Y) < y2; + } + else + { + hit = Math.Max(a.X, b.X) > x1 && Math.Min(a.X, b.X) < x2 + && Math.Max(a.Y, b.Y) > y1 && Math.Min(a.Y, b.Y) < y2; + } + + if (hit) + { + return true; + } + } + } + + return false; + } + + /// + /// Give a flowchart decision's edges DISTINCT diamond vertices — the classic flowchart idiom. + /// Diamonds are point-anchored (every bbox face midpoint is a vertex, anchors never spread), so + /// two edges resolving to the same face land on the identical point and share a trunk out of the + /// diamond, reading as one line that forks. For each diamond, endpoints on a crowded face keep + /// the axis-aligned one there and move each laterally-displaced one to the side vertex — Left/Right + /// for a Top/Bottom face, Top/Bottom for a Left/Right face — matching its far endpoint's direction. + /// Contended vertices fall back to the least-loaded one (preferring the original face), so where + /// the four vertices allow it every edge gets its own; overflow piles on the least-loaded vertex. + /// Rank-aware: a moved cross-rank edge still travels the primary axis after the short lateral hop + /// off the vertex. Deterministic — ties break on edge id. Flowchart-only, so architecture/state/ + /// class diamond routing (and its frozen goldens) stays byte-identical. + /// + private static void ReassignDiamondSides(DiagramModel model, EdgePrep?[] prep) + { + const double LateralEps = 8; // a far endpoint within this of the diamond's axis is "aligned" + + var diamonds = model.Nodes.Where(n => n.Shape == NodeShape.Diamond).Select(n => n.Id).ToHashSet(); + if (diamonds.Count == 0) + { + return; + } + + // Endpoints touching each diamond: (edge index, whether the diamond is this edge's `from`). + var endpoints = new Dictionary>(); + for (var i = 0; i < model.Edges.Count; i++) + { + if (prep[i] is null) + { + continue; + } + + var e = model.Edges[i]; + if (e.From == e.To) + { + continue; + } + + if (diamonds.Contains(e.From)) { Bucket(endpoints, e.From).Add((i, true)); } + if (diamonds.Contains(e.To)) { Bucket(endpoints, e.To).Add((i, false)); } + } + + foreach (var (_, eps) in endpoints) + { + var rect = eps[0].IsFrom ? prep[eps[0].Idx]!.From : prep[eps[0].Idx]!.To; + double cx = rect.X + rect.W / 2, cy = rect.Y + rect.H / 2; + + Side SideOf((int Idx, bool IsFrom) ep) => ep.IsFrom ? prep[ep.Idx]!.FromSide : prep[ep.Idx]!.ToSide; + Rect FarOf((int Idx, bool IsFrom) ep) => ep.IsFrom ? prep[ep.Idx]!.To : prep[ep.Idx]!.From; + // Signed lateral offset of the far endpoint from the diamond centre, on the axis parallel + // to `side` (X for a Top/Bottom face, Y for a Left/Right one). + double Lateral((int Idx, bool IsFrom) ep, Side side) + { + var far = FarOf(ep); + return side is Side.Top or Side.Bottom ? far.X + far.W / 2 - cx : far.Y + far.H / 2 - cy; + } + + var load = new Dictionary + { + [Side.Top] = 0, [Side.Bottom] = 0, [Side.Left] = 0, [Side.Right] = 0, + }; + foreach (var ep in eps) + { + load[SideOf(ep)]++; + } + + // Only faces hosting more than one endpoint need splitting; the rest stay byte-identical. + var crowded = load.Where(kv => kv.Value > 1).Select(kv => kv.Key).ToHashSet(); + if (crowded.Count == 0) + { + continue; + } + + // Pull the laterally-displaced endpoints off their crowded face (an aligned one keeps it), + // then reassign each — strongest lateral lean first — to a vertex, resolving contention. + var movable = new List<(int Idx, bool IsFrom)>(); + foreach (var ep in eps) + { + var side = SideOf(ep); + if (crowded.Contains(side) && Math.Abs(Lateral(ep, side)) > LateralEps) + { + movable.Add(ep); + load[side]--; + } + } + + foreach (var ep in movable + .OrderByDescending(x => Math.Abs(Lateral(x, SideOf(x)))) + .ThenBy(x => model.Edges[x.Idx].Id, StringComparer.Ordinal)) + { + var origSide = SideOf(ep); + var lateral = Lateral(ep, origSide); + var vertical = origSide is Side.Top or Side.Bottom; + var lowAlt = vertical ? Side.Left : Side.Top; // far endpoint leans negative + var highAlt = vertical ? Side.Right : Side.Bottom; // ...leans positive + var desired = lateral < 0 ? lowAlt : highAlt; + + // Take the desired vertex when it is free; otherwise the least-loaded, preferring the + // desired then the original face so a return edge settles back onto the vertex it left. + var chosen = load[desired] == 0 + ? desired + : new[] { desired, origSide, lowAlt, highAlt, Side.Top, Side.Bottom, Side.Left, Side.Right } + .OrderBy(s => load[s]).First(); + + load[chosen]++; + prep[ep.Idx] = ep.IsFrom + ? prep[ep.Idx]! with { FromSide = chosen } + : prep[ep.Idx]! with { ToSide = chosen }; + } + } + } + /// /// Spread anchors of edges sharing a node face along it, ordered by their far /// endpoints — a port of svg.ts:anchorShifts. Keeps fan-in/out lines diff --git a/src/Beck/Route/OrthogonalRouter.cs b/src/Beck/Route/OrthogonalRouter.cs index 990f9cc..8ac95d6 100644 --- a/src/Beck/Route/OrthogonalRouter.cs +++ b/src/Beck/Route/OrthogonalRouter.cs @@ -38,7 +38,11 @@ internal sealed record RouteRequest( // Perpendicular inward nudge for a slanted face (parallelogram): a Left/Right anchor slides // inward by skew/2 in x so it lands on the slanted face rather than the bbox edge. 0 for every // straight-faced shape — byte-identical. - double FromNudge = 0, double ToNudge = 0); + double FromNudge = 0, double ToNudge = 0, + // Mindmap edges use a fixed cubic instead of the proportional 0.4·span S-curve: both control points + // hang off the parent's face x by this offset (±40 out of the root, ±35 out of rank 1), so a branch's + // children fan from one point as clean equal-curvature threads. 0 keeps the legacy proportional form. + double MindMapOffset = 0); internal sealed record RoutedPath(string D, IReadOnlyList Points); @@ -297,22 +301,59 @@ private static IEnumerable LaneCandidates( var blockers = Blocking(a, b, obstacles); double aC = vertical ? a.X : a.Y, bC = vertical ? b.X : b.Y; + // The near-anchor row/column normally sits a fixed ChannelOffset out from the anchor. That + // is enough clearance when nothing else shares the anchor's rank, but a step candidate with + // several close siblings (e.g. a decision fanning out over a crowded rank) can put another + // node's face exactly there. Push the row/column further out — past that obstacle plus + // LanePad — the same "walk past what's in the way" move SameFaceLoop already makes for a + // shared-face loop. A row that starts clear is untouched: this only ever adds clearance. + double PushClear(double row, double lo, double hi, double dir) + { + for (var i = 0; i < obstacles.Count; i++) + { + Rect? hit = null; + foreach (var o in obstacles) + { + double oCLo = vertical ? o.X : o.Y, oCHi = vertical ? o.X + o.W : o.Y + o.H; + double oLo = vertical ? o.Y : o.X, oHi = vertical ? o.Y + o.H : o.X + o.W; + if (oCLo < hi && oCHi > lo && row > oLo + 0.01 && row < oHi - 0.01) + { + hit = o; + break; + } + } + if (hit is not { } o2) + { + break; + } + + double oLo2 = vertical ? o2.Y : o2.X, oHi2 = vertical ? o2.Y + o2.H : o2.X + o2.W; + row = dir > 0 ? oHi2 + LanePad : oLo2 - LanePad; + } + return ClampLane(row, bounds is null ? null : vertical ? bounds.Value.H : bounds.Value.W); + } + List Build(double lane) { if (vertical) { double s = Math.Sign(b.Y - a.Y); var dirY = s != 0 ? s : 1; - double ch1 = a.Y + dirY * ChannelOffset, ch2 = b.Y - dirY * ChannelOffset; + double ch1 = PushClear(a.Y + dirY * ChannelOffset, Math.Min(a.X, lane), Math.Max(a.X, lane), dirY); + double ch2 = PushClear(b.Y - dirY * ChannelOffset, Math.Min(lane, b.X), Math.Max(lane, b.X), -dirY); return [a, new(a.X, ch1), new(lane, ch1), new(lane, ch2), new(b.X, ch2), b]; } double sx = Math.Sign(b.X - a.X); var dirX = sx != 0 ? sx : 1; - double cx1 = a.X + dirX * ChannelOffset, cx2 = b.X - dirX * ChannelOffset; + double cx1 = PushClear(a.X + dirX * ChannelOffset, Math.Min(a.Y, lane), Math.Max(a.Y, lane), dirX); + double cx2 = PushClear(b.X - dirX * ChannelOffset, Math.Min(lane, b.Y), Math.Max(lane, b.Y), -dirX); return [a, new(cx1, a.Y), new(cx1, lane), new(cx2, lane), new(cx2, b.Y), b]; } // Nudge a lane that sits inside the stub zone of an anchor outward, away from that anchor, // so the run into the turn is a segment rather than a nub. Snapping onto the anchor is the - // better answer when it is safe; this is the other way out of the same zone. + // better answer when it is safe; this is the other way out of the same zone. The lane + // arriving here is already clamped inside the canvas (LaneCandidates), but an anchor sitting + // near the border (a small node close to the edge, e.g. a flowchart terminator) can still + // push it back out — re-clamp so the nudge never lands off-canvas. double Push(double lane) { foreach (var c in new[] { aC, bC }) @@ -323,7 +364,7 @@ double Push(double lane) lane = c + (d < 0 ? -LaneSnap : LaneSnap); } } - return lane; + return ClampLane(lane, bounds is null ? null : vertical ? bounds.Value.W : bounds.Value.H); } foreach (var raw in LaneCandidates(blockers, obstacles, vertical, bounds, (aC + bC) / 2)) @@ -549,7 +590,7 @@ private static List OrthogonalPolyline( return [a, corner, b]; } - private static string SCurve(Point a, Point b, Side fromSide) + private static string SCurve(Point a, Point b, Side fromSide, double mmOffset) { string S(double n) => Js.Str(n); if (IsVertical(fromSide)) @@ -557,6 +598,13 @@ private static string SCurve(Point a, Point b, Side fromSide) var off = (b.Y - a.Y) * 0.4; return $"M {S(a.X)} {S(a.Y)} C {S(a.X)} {S(a.Y + off)}, {S(b.X)} {S(b.Y - off)}, {S(b.X)} {S(b.Y)}"; } + // Mindmap: fixed cubic — both controls hang off the parent's face x by mmOffset, giving equal- + // curvature sibling threads that fan from the single face midpoint. Right face bends +x, left −x. + if (mmOffset != 0) + { + var cx = a.X + (fromSide == Side.Left ? -mmOffset : mmOffset); + return $"M {S(a.X)} {S(a.Y)} C {S(cx)} {S(a.Y)}, {S(cx)} {S(b.Y)}, {S(b.X)} {S(b.Y)}"; + } var offx = (b.X - a.X) * 0.4; return $"M {S(a.X)} {S(a.Y)} C {S(a.X + offx)} {S(a.Y)}, {S(b.X - offx)} {S(b.Y)}, {S(b.X)} {S(b.Y)}"; } @@ -598,7 +646,7 @@ public static RoutedPath RouteEdge(RouteRequest req) if (req.Curve == EdgeCurve.S) { - return new RoutedPath(SCurve(a, b, fromSide), [a, b]); + return new RoutedPath(SCurve(a, b, fromSide, req.MindMapOffset), [a, b]); } var poly = TryStraighten(req.From, req.To, fromSide, toSide, req.FromFanned, req.ToFanned, req.Obstacles, ref a, ref b) diff --git a/src/Beck/Svg/Stylesheet.cs b/src/Beck/Svg/Stylesheet.cs index 67942a5..3fa4c7c 100644 --- a/src/Beck/Svg/Stylesheet.cs +++ b/src/Beck/Svg/Stylesheet.cs @@ -15,7 +15,7 @@ internal static class Stylesheet private static string Sw(double n) => SvgWriter.Num(n); private static string P(int n) => SvgWriter.Int(n); - public static string Emit(string h, string fontFamily, string monoFamily, ThemeMode theme, BeckStyle style, ThemeHooks hooks) + public static string Emit(string h, string fontFamily, string monoFamily, ThemeMode theme, BeckStyle style, ThemeHooks hooks, bool mindMap = false) { var sb = new StringBuilder(); var scope = $".b-{h}"; @@ -118,6 +118,16 @@ void Block(string selector, IReadOnlyList<(string Name, string Value)> tokens) sb.Append($"{scope} .beck-status-bg{{fill:color-mix(in srgb, var(--beck-accent) {P(mix.StatusPill)}%, transparent);}}"); sb.Append($"{scope} .beck-status-text{{fill:var(--beck-accent);}}"); + // mindmap leaf pill (design handoff): accent-tinted fill + hairline accent border, no shadow. + // Defined after .beck-node so the equal-specificity overrides win. .beck-mm-planned is the faint + // "planned" label on a ghost branch's cards. Emitted only for mindmaps so the four shipping types + // stay byte-identical. + if (mindMap) + { + sb.Append($"{scope} .beck-mm-leaf{{fill:color-mix(in srgb, var(--beck-accent) 8%, var(--beck-node-bg));stroke:color-mix(in srgb, var(--beck-accent) 35%, var(--beck-node-border));stroke-width:1;filter:none;}}"); + sb.Append($"{scope} .beck-mm-planned{{fill:var(--beck-text-faint);}}"); + } + // group sb.Append($"{scope} .beck-group{{fill:none;stroke:var(--beck-group-border);stroke-width:{Sw(geo.GroupStroke)};stroke-dasharray:{strokes.GroupDash};}}"); sb.Append($"{scope} .beck-group-label-bg{{fill:var(--beck-surface);}}"); diff --git a/src/Beck/Svg/SvgRenderer.cs b/src/Beck/Svg/SvgRenderer.cs index f3cab59..0a0d836 100644 --- a/src/Beck/Svg/SvgRenderer.cs +++ b/src/Beck/Svg/SvgRenderer.cs @@ -50,7 +50,8 @@ public static string Render(DiagramModel model, ITextMeasurer measurer, string h // The non-empty pill texts a node's flow swaps through — sized into the card up front // (row height + widest pill), since compiled CSS can't grow the box the way the live-DOM // engine could when a status landed on a status-less node. - var sizes = model.Nodes.ToDictionary(n => n.Id, n => CardSizer.Measure(n, measurer, geo, style.Typography.Roles, style.Typography.TitlePrefix, style.Typography.TitleSuffix, NonEmptyStatusTexts(StatesFor(n.Id)))); + var mindMap = model.Meta.Type == DiagramType.MindMap; + var sizes = model.Nodes.ToDictionary(n => n.Id, n => CardSizer.Measure(n, measurer, geo, style.Typography.Roles, style.Typography.TitlePrefix, style.Typography.TitleSuffix, NonEmptyStatusTexts(StatesFor(n.Id)), mindMap)); var extraDefs = ""; // Per-edge gradient collected by Edge() when the style paints luminous gradient edges // (glow). One userSpaceOnUse gradient per gradient-stroked edge, run along that edge's own @@ -86,7 +87,7 @@ public static string Render(DiagramModel model, ITextMeasurer measurer, string h { if (layout.Nodes.TryGetValue(model.Nodes[i].Id, out var r)) { - body.Append(Node(model.Nodes[i], r, measurer, hash, i, style, guard, StatesFor(model.Nodes[i].Id))); + body.Append(Node(model.Nodes[i], r, measurer, hash, i, style, guard, StatesFor(model.Nodes[i].Id), mindMap)); } } @@ -94,7 +95,9 @@ public static string Render(DiagramModel model, ITextMeasurer measurer, string h } else { - layout = LayeredLayout.Compute(model, sizes); + layout = model.Meta.Type == DiagramType.MindMap + ? MindMapLayout.Compute(model, sizes) + : LayeredLayout.Compute(model, sizes); var edges = EdgePainter.RouteEdges(model, layout); // The effective (possibly bowed) path per edge — computed once and reused for both the // rendered edge and its FlowEdge, so a packet rides exactly the drawn curve. At classic @@ -156,7 +159,7 @@ public static string Render(DiagramModel model, ITextMeasurer measurer, string h { if (layout.Nodes.TryGetValue(model.Nodes[i].Id, out var r)) { - body.Append(Node(model.Nodes[i], r, measurer, hash, i, style, guard, StatesFor(model.Nodes[i].Id))); + body.Append(Node(model.Nodes[i], r, measurer, hash, i, style, guard, StatesFor(model.Nodes[i].Id), mindMap)); } } @@ -235,7 +238,7 @@ public static string Render(DiagramModel model, ITextMeasurer measurer, string h var svg = new StringBuilder(); svg.Append($""); - svg.Append(""); + svg.Append(""); svg.Append("").Append(markers.Defs).Append(extraDefs).Append(animDefs).Append(edgeDefs).Append(Stylesheet.StyleDefs(hash, style)).Append(""); svg.Append(TitleBlock(model, w, style)); svg.Append($"").Append(body).Append(""); @@ -542,7 +545,7 @@ private static IEnumerable Bends(IReadOnlyList pts) } private static string Node(NodeModel node, Rect rect, ITextMeasurer m, string hash, int idx, BeckStyle style, - bool guard, IReadOnlyList<(string Text, string Color)>? statusStates = null) + bool guard, IReadOnlyList<(string Text, string Color)>? statusStates = null, bool mindMap = false) { var sb = new StringBuilder(); var accentStyle = $"--beck-accent:{node.Accent}"; @@ -574,6 +577,20 @@ private static string Node(NodeModel node, Rect rect, ITextMeasurer m, string ha sb.Append(""); double w = rect.W, h = rect.H; + // Mindmap nodes use depth-role emitters: a leaf pill (accent-tinted), or a root/rank-1 heading card + // (icon chip + semantic status / ghost "planned"). A content card (items/body) and any other shape + // fall through to the shared emitters below. + if (mindMap && EmitMindMapNode(sb, node, w, h, m, hash, style, guard)) + { + sb.Append(""); + if (node.Href != null) + { + sb.Append(""); + } + + return sb.ToString(); + } + switch (node.Shape) { case NodeShape.Pill: EmitPill(sb, node, w, h, m, hash, style, guard); break; @@ -933,6 +950,119 @@ private static void EmitGhost(StringBuilder sb, NodeModel node, double w, double } } + /// Render a mindmap node by its depth role (handoff "Branch accents"): a leaf pill, or a + /// root/rank-1 heading card. Returns false for a content card (items/body at rank 2+) so the caller + /// falls back to the shared card emitter. Ghost branches route here too (dashed, neutral, "planned"). + private static bool EmitMindMapNode(StringBuilder sb, NodeModel node, double w, double h, ITextMeasurer m, string hash, BeckStyle style, bool guard) + { + var ghost = node.Variant == NodeVariant.Ghost || node.Kind == NodeKind.Ghost; + if (node.Shape == NodeShape.Pill) + { + EmitMindMapLeaf(sb, node, w, h, m, hash, style, guard, ghost); + return true; + } + + var rank = (int)(node.Rank ?? 0); + var heading = node.Shape == NodeShape.Card && node.Items.Count == 0 && node.Body == null && rank <= 1; + if (ghost || heading) + { + EmitMindMapCard(sb, node, w, h, m, hash, style, guard, rank, ghost); + return true; + } + + return false; + } + + /// A mindmap leaf pill: accent-tinted fill + hairline accent border (.beck-mm-leaf), no + /// shadow, with an Inter 12/500 label left-aligned at 16px (node internals stay LTR on both sides). + /// Ghost leaves render the shared dashed transparent treatment with a muted label. + private static void EmitMindMapLeaf(StringBuilder sb, NodeModel node, double w, double h, ITextMeasurer m, string hash, BeckStyle style, bool guard, bool ghost) + { + var bi = style.Geometry.NodeBorderInset; + var cls = ghost ? "beck-node beck-node--ghost" : "beck-node beck-node--pill beck-mm-leaf"; + sb.Append(Artwork.Rect(style, cls, bi, bi, w - 2 * bi, h - 2 * bi, h / 2, hash + ":" + node.Id, shadow: false)); + LineSpec(sb, style.Typography.DecorateTitle(node.Title), ghost ? "beck-ghost-label" : "beck-node-title", + CardSizer.MindMapLeafPadX / 2, h / 2, m, CardSizer.MindMapLeafLabel, guard); + } + + /// A mindmap root/rank-1 heading card: accent-bordered box (dashed + shadowless when ghost), + /// an optional icon chip (34 at root, 30 at rank 1), and a centred stack of title (+ optional subtitle) + /// (+ a semantic status pill, or a faint "planned" label on a ghost branch). The box was floored to hold + /// the single-line heading, so the title never wraps here. + private static void EmitMindMapCard(StringBuilder sb, NodeModel node, double w, double h, ITextMeasurer m, string hash, BeckStyle style, bool guard, int rank, bool ghost) + { + var geo = style.Geometry; + var bi = geo.NodeBorderInset; + var radius = rank == 0 ? geo.CardRadius : 12; + sb.Append(Artwork.Rect(style, ghost ? "beck-node beck-node--ghost" : "beck-node", + bi, bi, w - 2 * bi, h - 2 * bi, radius, hash + ":" + node.Id, shadow: !ghost)); + + var hasIcon = Icons.ResolveIcon(node.Icon) != null; + var chipW = rank == 0 ? CardSizer.MindMapRootChip : CardSizer.MindMapRankChip; + var iconSize = rank == 0 ? 20.0 : 18.0; + var padHalf = geo.CardPadX / 2; + var textX = padHalf + (hasIcon ? chipW + geo.IconGap : 0); + if (hasIcon) + { + var chipY = h / 2 - chipW / 2; + sb.Append($""); + sb.Append(IconSvg(node.Icon!, padHalf + (chipW - iconSize) / 2, chipY + (chipW - iconSize) / 2, iconSize)); + } + + double titleLine = geo.CardTitleLine, subLine = geo.CardSubLine, gap = geo.TextGap; + var hasSub = node.Subtitle != null; + var showStatus = !ghost && node.Status != null; + var statusChipH = geo.StatusChipH; + var plannedLine = geo.StatusInlineLine; + var stackH = titleLine + + (hasSub ? gap + subLine : 0) + + (showStatus ? gap + statusChipH : 0) + + (ghost ? gap + plannedLine : 0); + var y = h / 2 - stackH / 2; + + Line(sb, style.Typography.DecorateTitle(node.Title), ghost ? "beck-node-subtitle" : "beck-node-title", + textX, y + titleLine / 2, m, style, FontRole.CardTitle, guard); + y += titleLine; + if (hasSub) + { + y += gap; + Line(sb, node.Subtitle!, "beck-node-subtitle", textX, y + subLine / 2, m, style, FontRole.CardSubtitle, guard); + y += subLine; + } + + if (showStatus) + { + StatusPill(sb, node.Status!, MindMapStatusColor(node.Status!), textX, y + gap, statusChipH, m, style, guard); + } + else if (ghost) + { + Line(sb, node.Status ?? "planned", "beck-mm-planned", textX, y + gap + plannedLine / 2, m, style, FontRole.StatusInline, guard); + } + } + + /// The semantic pill colour for a mindmap status keyword (handoff): the status carries its own + /// meaning independent of the branch accent. Unknown keywords fall back to the branch accent. + private static string MindMapStatusColor(string status) => status.Trim().ToLowerInvariant() switch + { + "complete" or "done" or "shipped" or "live" => "var(--beck-success)", + "in progress" or "in-progress" or "active" or "wip" or "building" => "var(--beck-warn)", + "blocked" or "failed" or "at risk" or "at-risk" => "var(--beck-danger)", + "review" or "in review" or "in-review" => "var(--beck-info)", + "planned" or "backlog" or "later" or "todo" => "var(--beck-neutral)", + _ => "var(--beck-accent)", + }; + + /// A left-aligned single-line text at an explicit — used for the + /// mindmap leaf label, whose Inter 12/500 type isn't a style role. Measured through a sans role so a + /// custom measurer still honours the spec; the textLength guard pins the drawn advance to it. + private static void LineSpec(StringBuilder sb, string text, string cls, double x, double cy, ITextMeasurer m, FontRoleSpec spec, bool guard) + { + var tl = m.Measure(text, FontRole.PillTitle, spec).Width; + sb.Append($"") + .Append(SvgWriter.Text(text)).Append(""); + } + private static void Line(StringBuilder sb, string text, string cls, double x, double cy, ITextMeasurer m, BeckStyle style, FontRole role, bool guard) { var spec = style.Typography.Roles.Of(role); diff --git a/src/Beck/Text/CardSizer.cs b/src/Beck/Text/CardSizer.cs index 0787c3d..4f62e80 100644 --- a/src/Beck/Text/CardSizer.cs +++ b/src/Beck/Text/CardSizer.cs @@ -25,6 +25,16 @@ internal static class CardSizer /// measured advance matches the run draws (measured == drawn). internal const string ItemBullet = "• "; + // ---- mindmap depth roles (design handoff "Branch accents"): depth = size + shape ---- + /// Root card 210×68, rank-1 card 190×56, leaf pill height 30, and the leaf pill's horizontal + /// text padding (16px each side). These bypass the auto card box model so depth reads as fixed size. + internal const double MindMapRootW = 210, MindMapRootH = 68, MindMapRankW = 190, MindMapRankH = 56; + internal const double MindMapRootChip = 34, MindMapRankChip = 30, MindMapLeafH = 30, MindMapLeafPadX = 32; + + /// The leaf pill's label typography (Inter 12 / 500) — measured here and drawn by the renderer + /// so the pill hugs the same run the textLength guard pins. + internal static readonly FontRoleSpec MindMapLeafLabel = new(false, 500, 12, 0, false); + /// Measure a node's card to its rounded border-box size. The box-model constants come from /// and the per-role typography from (both @@ -32,13 +42,20 @@ internal static class CardSizer /// is what makes a remapped role (heavier/uppercased/other-family) size a matching box instead of a /// classic one the textLength guard would squeeze the real run into. public static Size Measure(NodeModel node, ITextMeasurer m, StyleGeometry? geometry = null, FontRoleTable? roles = null, - string titlePrefix = "", string titleSuffix = "", IReadOnlyList? flowStatuses = null) + string titlePrefix = "", string titleSuffix = "", IReadOnlyList? flowStatuses = null, bool mindMap = false) { var g = geometry ?? BeckStyle.Classic.Geometry; var r = roles ?? BeckStyle.Classic.Typography.Roles; // The style's node-title decoration (terminal's [brackets]) applied to the measured title, so the // box is sized for the same run the renderer draws + word-wraps — the textLength guard stays matched. var title = Decorate(node.Title, titlePrefix, titleSuffix); + // Mindmap nodes take fixed depth-role sizes (leaf pill / root / rank-1 card); a content card + // (items/body) at any depth falls through to the auto box model. Authored width: always wins. + if (mindMap && node.Width is null && MindMap(node, m, g, r, title) is { } depthSize) + { + return depthSize; + } + return node.Shape switch { NodeShape.Pill => Pill(node, m, g, r, title), @@ -58,6 +75,35 @@ private static string Decorate(string title, string pre, string suf) => private static bool IsGhost(NodeModel n) => n.Variant == NodeVariant.Ghost || n.Kind == NodeKind.Ghost; + /// Depth-based fixed sizing for a mindmap node (handoff "Branch accents"): a leaf pill hugs its + /// 12/500 label at height 30; the root card is 210×68 and a rank-1 card 190×56 (each floored so a long + /// heading still fits). Returns null for a content card (items/body) — it uses the auto + /// box. Ghost only changes rendering, not the box, so it is not special-cased here. + private static Size? MindMap(NodeModel node, ITextMeasurer m, StyleGeometry g, FontRoleTable r, string title) + { + if (node.Shape == NodeShape.Pill) + { + var labelW = m.Measure(title, FontRole.PillTitle, MindMapLeafLabel).Width; + return new Size(Round(Math.Ceiling(labelW) + MindMapLeafPadX), MindMapLeafH); + } + + // A rank 2+ heading is a pill (above); a rank 2+ card always carries content → auto box (null). + if (node.Shape != NodeShape.Card || node.Items.Count > 0 || node.Body != null) + { + return null; + } + + var root = (node.Rank ?? 0) == 0; + var hasIcon = Icons.ResolveIcon(node.Icon) != null; + var iconBlock = hasIcon ? (root ? MindMapRootChip : MindMapRankChip) + g.IconGap : 0; + var titleW = W(m, r, title, FontRole.CardTitle); + var subW = node.Subtitle != null ? W(m, r, node.Subtitle, FontRole.CardSubtitle) : 0; + var natural = Math.Ceiling(Math.Max(titleW, subW)) + iconBlock + g.CardPadX + g.MeasureBorder; + return root + ? new Size(Round(Math.Max(MindMapRootW, natural)), MindMapRootH) + : new Size(Round(Math.Max(MindMapRankW, natural)), MindMapRankH); + } + /// Measured advance width of at , resolved /// through the active style's table (classic when unremapped). private static double W(ITextMeasurer m, FontRoleTable roles, string text, FontRole role) => diff --git a/tests/Beck.Tests/ClassicSvgGoldenTests.cs b/tests/Beck.Tests/ClassicSvgGoldenTests.cs index 5530d33..f8b797f 100644 --- a/tests/Beck.Tests/ClassicSvgGoldenTests.cs +++ b/tests/Beck.Tests/ClassicSvgGoldenTests.cs @@ -25,6 +25,11 @@ public static IEnumerable Diagrams() => ["arch-kitchen"], ["seq-kitchen"], ["class"], + ["flowchart-simple"], + ["flowchart-branchy"], + ["mindmap-simple"], + ["mindmap-kitchen"], + ["mindmap-status"], ]; [Theory] @@ -78,7 +83,7 @@ public void Regenerate() } var srcGoldens = Path.Combine(SourceDir(), "Goldens", "svg"); - foreach (var name in new[] { "arch-kitchen", "seq-kitchen", "class" }) + foreach (var name in new[] { "arch-kitchen", "seq-kitchen", "class", "mindmap-simple", "mindmap-kitchen", "mindmap-status" }) { var yaml = File.ReadAllText(Path.Combine(_corpusDir, name + ".yaml")); File.WriteAllText(Path.Combine(srcGoldens, name + ".svg"), diff --git a/tests/Beck.Tests/CleanLines/CleanLineTests.cs b/tests/Beck.Tests/CleanLines/CleanLineTests.cs index 663c3f8..166cf0b 100644 --- a/tests/Beck.Tests/CleanLines/CleanLineTests.cs +++ b/tests/Beck.Tests/CleanLines/CleanLineTests.cs @@ -35,6 +35,27 @@ private sealed record Baseline( double StraightRate, double MicroJogsPerEdge, double MergedRunsPerEdge, double SkewedFaceRate, double BendsPerEdge, double TightEdgeRate); + /// + /// The butterfly's own soft scorecard (mindmap edges are S-curves, so the orthogonal + /// metrics don't apply). All three ratchet lower-is-better: less wasted + /// canvas per node, shorter mean threads, and a half-balance nearer 1.0 (perfectly even halves). + /// + private sealed record MindmapBaseline(double AreaPerNode, double MeanEdgeLength, double HalfBalance); + + /// + /// Goldens/cleanlines.json holds one baseline per fuzzed diagram type, keyed by name. The + /// architecture entry is the original scorecard (values unchanged by the flowchart and mindmap + /// chaos monkeys' introduction); flowchart and mindmap are their own keys so none ever contend. + /// + private sealed record BaselineFile(Baseline Architecture, Baseline Flowchart, MindmapBaseline Mindmap); + + private static BaselineFile ReadBaselines() => JsonSerializer.Deserialize( + File.ReadAllText(Path.GetFullPath(BaselinePath)), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + + private static void WriteBaselines(BaselineFile file) => File.WriteAllText(Path.GetFullPath(BaselinePath), + JsonSerializer.Serialize(file, new JsonSerializerOptions { WriteIndented = true })); + [Fact] public void ChaosMonkey_NoHardViolations() { @@ -57,15 +78,204 @@ public void ChaosMonkey_NoHardViolations() + $"(reproduce with BECK_SEED= ... --filter DumpSeed):\n " + string.Join("\n ", failures)); } + /// + /// The flowchart sibling of : same pipeline, same hard + /// gate, over instead. Reproduce a failing seed with + /// BECK_SEED=<seed> BECK_FUZZ=flowchart dotnet test --filter FullyQualifiedName~DumpSeed. + /// + [Fact] + public void ChaosMonkey_Flowchart_NoHardViolations() + { + var failures = new List(); + for (var seed = 0; seed < SeedCount; seed++) + { + var r = LineQuality.Analyze(DiagramFuzzer.FlowchartYaml(seed)); + foreach (var d in r.Violations.Where(d => d.IsHard).Take(3)) + { + failures.Add($"seed {seed}: [{d.Kind}] {d.Detail}"); + } + + if (failures.Count > 30) + { + break; + } + } + Assert.True(failures.Count == 0, + $"{failures.Count} routing violations over {SeedCount} fuzzed flowcharts " + + $"(reproduce with BECK_SEED= BECK_FUZZ=flowchart ... --filter DumpSeed):\n " + string.Join("\n ", failures)); + } + [Fact] public void ChaosMonkey_AestheticsDoNotRegress() + { + var (got, edges, faces, worst) = Aggregate(DiagramFuzzer.Yaml); + + _out.WriteLine(Scorecard(got, edges, faces)); + _out.WriteLine(""); + _out.WriteLine("Worst offenders (seed → micro-jogs + merged runs per edge):"); + foreach (var w in worst.OrderByDescending(w => w.Score).Take(8)) + { + _out.WriteLine(FormattableString.Invariant( + $" seed {w.Seed,4} score {w.Score:0.00} edges {w.R.Edges,2} jogs {w.R.MicroJogs,2} merged {w.R.MergedRuns,2} straight {w.R.StraightRate:0%}")); + } + + if (Environment.GetEnvironmentVariable("BECK_REGEN") == "1") + { + var current = File.Exists(Path.GetFullPath(BaselinePath)) ? ReadBaselines() : new BaselineFile(got, got, new MindmapBaseline(0, 0, 0)); + WriteBaselines(current with { Architecture = got }); + return; + } + + var want = ReadBaselines().Architecture; + var regressions = Compare(got, want); + Assert.True(regressions.Count == 0, + "Line quality regressed against Goldens/cleanlines.json (architecture):\n " + + string.Join("\n ", regressions) + + "\n\nIf the change is an intentional trade, re-record with BECK_REGEN=1."); + } + + /// + /// The flowchart sibling of , ratcheted against + /// its own Flowchart key in Goldens/cleanlines.json so the two fuzzers never share — + /// or perturb — a baseline. Same BECK_REGEN=1 regen path. + /// + [Fact] + public void ChaosMonkey_Flowchart_AestheticsDoNotRegress() + { + var (got, edges, faces, worst) = Aggregate(DiagramFuzzer.FlowchartYaml); + + _out.WriteLine(Scorecard(got, edges, faces)); + _out.WriteLine(""); + _out.WriteLine("Worst offenders (seed → micro-jogs + merged runs per edge):"); + foreach (var w in worst.OrderByDescending(w => w.Score).Take(8)) + { + _out.WriteLine(FormattableString.Invariant( + $" seed {w.Seed,4} score {w.Score:0.00} edges {w.R.Edges,2} jogs {w.R.MicroJogs,2} merged {w.R.MergedRuns,2} straight {w.R.StraightRate:0%}")); + } + + if (Environment.GetEnvironmentVariable("BECK_REGEN") == "1") + { + var current = File.Exists(Path.GetFullPath(BaselinePath)) ? ReadBaselines() : new BaselineFile(got, got, new MindmapBaseline(0, 0, 0)); + WriteBaselines(current with { Flowchart = got }); + return; + } + + var want = ReadBaselines().Flowchart; + var regressions = Compare(got, want); + Assert.True(regressions.Count == 0, + "Line quality regressed against Goldens/cleanlines.json (flowchart):\n " + + string.Join("\n ", regressions) + + "\n\nIf the change is an intentional trade, re-record with BECK_REGEN=1."); + } + + /// + /// The mindmap chaos monkey's hard gate: every fuzzed butterfly must route without an off-canvas + /// coordinate, a node overlap, a broken tree shape, or an off-centre root. Mindmap violations are + /// all hard by construction (there is no soft Defect among them), so none is filtered out. + /// Reproduce a failing seed with + /// BECK_SEED=<seed> BECK_FUZZ=mindmap dotnet test --filter FullyQualifiedName~DumpSeed. + /// + [Fact] + public void ChaosMonkey_Mindmap_NoHardViolations() + { + var failures = new List(); + for (var seed = 0; seed < SeedCount; seed++) + { + var r = MindmapQuality.Analyze(DiagramFuzzer.MindMapYaml(seed)); + foreach (var d in r.Violations.Take(3)) + { + failures.Add($"seed {seed}: [{d.Kind}] {d.Detail}"); + } + + if (failures.Count > 30) + { + break; + } + } + Assert.True(failures.Count == 0, + $"{failures.Count} butterfly violations over {SeedCount} fuzzed mindmaps " + + $"(reproduce with BECK_SEED= BECK_FUZZ=mindmap ... --filter DumpSeed):\n " + string.Join("\n ", failures)); + } + + /// + /// The mindmap sibling of the aesthetics ratchets, against its own Mindmap key in + /// Goldens/cleanlines.json. All three metrics are lower-is-better. Same BECK_REGEN=1 + /// regen path — it seeds the Mindmap key alone, leaving Architecture and Flowchart byte-identical. + /// + [Fact] + public void ChaosMonkey_Mindmap_AestheticsDoNotRegress() + { + double area = 0, edge = 0, balance = 0; + var worst = new List<(double Balance, int Seed, MindmapReport R)>(); + for (var seed = 0; seed < SeedCount; seed++) + { + var r = MindmapQuality.Analyze(DiagramFuzzer.MindMapYaml(seed)); + area += r.Metrics.AreaPerNode; edge += r.Metrics.MeanEdgeLength; balance += r.Metrics.HalfBalance; + worst.Add((r.Metrics.HalfBalance, seed, r)); + } + var got = new MindmapBaseline(area / SeedCount, edge / SeedCount, balance / SeedCount); + + _out.WriteLine(FormattableString.Invariant($"mindmap scorecard over {SeedCount} butterflies")); + _out.WriteLine(FormattableString.Invariant($" area / node {got.AreaPerNode:0} px2")); + _out.WriteLine(FormattableString.Invariant($" mean edge length {got.MeanEdgeLength:0.0} px")); + _out.WriteLine(FormattableString.Invariant($" half balance {got.HalfBalance:0.000} (heavier / lighter half height)")); + _out.WriteLine(""); + _out.WriteLine("Most lopsided (seed → half balance):"); + foreach (var w in worst.OrderByDescending(w => w.Balance).Take(8)) + { + _out.WriteLine(FormattableString.Invariant( + $" seed {w.Seed,4} balance {w.Balance:0.00} nodes {w.R.Nodes,2} edges {w.R.Edges,2}")); + } + + if (Environment.GetEnvironmentVariable("BECK_REGEN") == "1") + { + var current = File.Exists(Path.GetFullPath(BaselinePath)) ? ReadBaselines() : new BaselineFile(default!, default!, got); + WriteBaselines(current with { Mindmap = got }); + return; + } + + var want = ReadBaselines().Mindmap; + var regressions = new List(); + void LowerIsBetter(string name, double g, double w, double tol) + { + if (g > w + tol) + { + regressions.Add(FormattableString.Invariant($"{name}: {g:0.000} > baseline {w:0.000}")); + } + } + LowerIsBetter("area per node", got.AreaPerNode, want.AreaPerNode, want.AreaPerNode * 0.02); + LowerIsBetter("mean edge length", got.MeanEdgeLength, want.MeanEdgeLength, 1.0); + LowerIsBetter("half balance", got.HalfBalance, want.HalfBalance, 0.02); + Assert.True(regressions.Count == 0, + "Mindmap aesthetics regressed against Goldens/cleanlines.json (mindmap):\n " + + string.Join("\n ", regressions) + + "\n\nIf the change is an intentional trade, re-record with BECK_REGEN=1."); + } + + /// + /// Determinism: a fuzzed mindmap renders byte-identically twice. Same YAML + same options ⇒ + /// byte-identical SVG is a load-bearing engine invariant; this exercises it over the mindmap + /// pipeline for 25 seeds spread across the fuzz range. + /// + [Fact] + public void Mindmap_RenderIsDeterministic() + { + for (var seed = 0; seed < SeedCount; seed += SeedCount / 25) + { + var yaml = DiagramFuzzer.MindMapYaml(seed); + Assert.Equal(BeckSvg.Render(yaml), BeckSvg.Render(yaml)); + } + } + + private static (Baseline Got, int Edges, int Faces, List<(double Score, int Seed, QualityReport R)> Worst) Aggregate( + Func yamlFor) { int edges = 0, straight = 0, jogs = 0, merged = 0, faces = 0, skewed = 0, bends = 0, tight = 0; var worst = new List<(double Score, int Seed, QualityReport R)>(); for (var seed = 0; seed < SeedCount; seed++) { - var r = LineQuality.Analyze(DiagramFuzzer.Yaml(seed)); + var r = LineQuality.Analyze(yamlFor(seed)); edges += r.Edges; straight += r.StraightEdges; jogs += r.MicroJogs; merged += r.MergedRuns; faces += r.Faces; skewed += r.SkewedFaces; bends += r.Bends; tight += r.TightEdges; @@ -83,26 +293,11 @@ public void ChaosMonkey_AestheticsDoNotRegress() BendsPerEdge: (double)bends / edges, TightEdgeRate: (double)tight / edges); - _out.WriteLine(Scorecard(got, edges, faces)); - _out.WriteLine(""); - _out.WriteLine("Worst offenders (seed → micro-jogs + merged runs per edge):"); - foreach (var w in worst.OrderByDescending(w => w.Score).Take(8)) - { - _out.WriteLine(FormattableString.Invariant( - $" seed {w.Seed,4} score {w.Score:0.00} edges {w.R.Edges,2} jogs {w.R.MicroJogs,2} merged {w.R.MergedRuns,2} straight {w.R.StraightRate:0%}")); - } - - if (Environment.GetEnvironmentVariable("BECK_REGEN") == "1") - { - File.WriteAllText(Path.GetFullPath(BaselinePath), - JsonSerializer.Serialize(got, new JsonSerializerOptions { WriteIndented = true })); - return; - } - - var want = JsonSerializer.Deserialize( - File.ReadAllText(Path.GetFullPath(BaselinePath)), - new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + return (got, edges, faces, worst); + } + private static List Compare(Baseline got, Baseline want) + { var regressions = new List(); void HigherIsBetter(string name, double g, double w, double tol = 0.005) { @@ -124,11 +319,7 @@ void LowerIsBetter(string name, double g, double w, double tol = 0.005) LowerIsBetter("skewed-face rate", got.SkewedFaceRate, want.SkewedFaceRate); LowerIsBetter("bends per edge", got.BendsPerEdge, want.BendsPerEdge, 0.01); LowerIsBetter("tight-clearance edges", got.TightEdgeRate, want.TightEdgeRate); - - Assert.True(regressions.Count == 0, - "Line quality regressed against Goldens/cleanlines.json:\n " - + string.Join("\n ", regressions) - + "\n\nIf the change is an intentional trade, re-record with BECK_REGEN=1."); + return regressions; } private static string Scorecard(Baseline b, int edges, int faces) => string.Join('\n', new[] @@ -144,7 +335,9 @@ private static string Scorecard(Baseline b, int edges, int faces) => string.Join /// /// Debugging affordance, not a gate: dumps one fuzzed seed's YAML, node boxes and routed - /// polylines so a monkey failure can be read by eye. + /// polylines so a monkey failure can be read by eye. Defaults to the architecture fuzzer; set + /// BECK_FUZZ=flowchart or BECK_FUZZ=mindmap to dump from + /// / instead. /// BECK_SEED=468 dotnet test --filter FullyQualifiedName~DumpSeed -l "console;verbosity=detailed" /// [Fact] @@ -155,7 +348,30 @@ public void DumpSeed() return; } - var yaml = DiagramFuzzer.Yaml(seed); + var fuzz = Environment.GetEnvironmentVariable("BECK_FUZZ"); + if (fuzz == "mindmap") + { + var mmYaml = DiagramFuzzer.MindMapYaml(seed); + _out.WriteLine(mmYaml); + var routed = MindmapQuality.Route(mmYaml); + var layout = routed.Layout; + var mm = MindmapQuality.Analyze(routed); + _out.WriteLine(FormattableString.Invariant( + $"seed {seed}: {mm.Nodes} nodes, {mm.Edges} edges, canvas {layout.Width}×{layout.Height}")); + foreach (var (id, rect) in layout.Nodes.OrderBy(k => k.Key)) + { + _out.WriteLine(FormattableString.Invariant( + $" node {id,-14} x {rect.X:0.#}..{rect.X + rect.W:0.#} y {rect.Y:0.#}..{rect.Y + rect.H:0.#}")); + } + + foreach (var d in mm.Violations) + { + _out.WriteLine($" !! [{d.Kind}] {d.Detail}"); + } + return; + } + + var yaml = fuzz == "flowchart" ? DiagramFuzzer.FlowchartYaml(seed) : DiagramFuzzer.Yaml(seed); _out.WriteLine(yaml); var r = LineQuality.Analyze(yaml); _out.WriteLine(Describe($"seed {seed}", yaml, r)); diff --git a/tests/Beck.Tests/CleanLines/DiagramFuzzer.cs b/tests/Beck.Tests/CleanLines/DiagramFuzzer.cs index 4236cf5..b1786d0 100644 --- a/tests/Beck.Tests/CleanLines/DiagramFuzzer.cs +++ b/tests/Beck.Tests/CleanLines/DiagramFuzzer.cs @@ -105,4 +105,264 @@ public static string Yaml(int seed) } return sb.ToString(); } + + private static readonly string[] _flowVerbs = + [ + "Validate", "Fetch", "Normalize", "Check", "Compute", "Persist", "Notify", "Enqueue", + "Retry", "Transform", "Merge", "Dispatch", "Archive", "Review", "Approve", "Escalate", + ]; + + /// + /// Deterministic type: flowchart generator mirroring 's DAG-by-construction + /// shape, but over steps/links with a random mix of kinds: exactly one start, a chance of one + /// end, and a spread of decision/io/process/terminator steps for the interior. Every decision + /// gets exactly two outgoing labeled links ("yes"/"no") when at least two downstream targets exist; + /// otherwise it degrades to whatever targets are available so the diagram never becomes unparsable. + /// + public static string FlowchartYaml(int seed) + { + var rng = new Random(seed); + var n = 4 + rng.Next(9); // 4..12 steps + var dir = _directions[rng.Next(_directions.Length)]; + var hasEnd = rng.Next(10) < 7; // ~70% + + // step 0 is always "start"; the last step is "end" when hasEnd, else an ordinary step. + var kinds = new string[n]; + kinds[0] = "start"; + for (var i = 1; i < n; i++) + { + if (hasEnd && i == n - 1) + { + kinds[i] = "end"; + continue; + } + + var roll = rng.Next(100); + kinds[i] = roll switch + { + < 25 => "decision", + < 40 => "io", + < 55 => "terminator", + _ => "process", + }; + } + + var sb = new StringBuilder(); + sb.AppendLine("type: flowchart"); + sb.AppendLine(CultureInfo.InvariantCulture, $"meta: {{ direction: {dir} }}"); + sb.AppendLine("steps:"); + var titles = new string[n]; + for (var i = 0; i < n; i++) + { + titles[i] = kinds[i] switch + { + "start" => "Start", + "end" => "End", + _ => string.Join(' ', Enumerable.Range(0, 1 + rng.Next(2)).Select(_ => _flowVerbs[rng.Next(_flowVerbs.Length)])), + }; + sb.Append(CultureInfo.InvariantCulture, $" - {{ id: s{i}, text: \"{titles[i]}\", kind: {kinds[i]} }}"); + sb.AppendLine(); + } + + // Links: every non-start step takes 1..3 parents from earlier steps (DAG-by-construction). + // Decisions among the parent pool get exactly two labeled outgoing links to distinct + // downstream targets when available, so both branches of every diamond render. + var links = new List<(int From, int To, string? Label)>(); + var seen = new HashSet<(int, int)>(); + void AddLink(int f, int t, string? label = null) + { + if (seen.Add((f, t))) + { + links.Add((f, t, label)); + } + } + + for (var i = 1; i < n; i++) + { + var parents = 1 + rng.Next(Math.Min(3, i)); + for (var p = 0; p < parents; p++) + { + var from = rng.Next(i); + AddLink(from, i); + } + } + + // Give every decision step exactly two outgoing links (yes/no) when it has at least two + // distinct downstream targets available; otherwise leave its natural fan-out alone. + for (var i = 0; i < n; i++) + { + if (kinds[i] != "decision") + { + continue; + } + + var downstream = Enumerable.Range(i + 1, n - i - 1).ToList(); + if (downstream.Count < 2) + { + continue; + } + + // Drop any links this decision already produced so we can lay down exactly two. + links.RemoveAll(l => l.From == i); + seen.RemoveWhere(k => k.Item1 == i); + + var t1 = downstream[rng.Next(downstream.Count)]; + int t2; + do + { + t2 = downstream[rng.Next(downstream.Count)]; + } while (t2 == t1); + + AddLink(i, t1, "yes"); + AddLink(i, t2, "no"); + } + + // Occasional merge fan-in: one extra parent wired into a later step. + if (n >= 5 && rng.Next(2) == 0) + { + var to = 2 + rng.Next(n - 2); + var from = rng.Next(to); + AddLink(from, to); + } + + // A back edge (against the flow) roughly a third of the time, a self loop a sixth — + // never sourced from a decision (its two branches are already fixed above) or targeting start. + if (n >= 4 && rng.Next(3) == 0) + { + int to = rng.Next(n / 2), from = n / 2 + rng.Next(n - n / 2); + if (from != to && kinds[from] != "decision" && to != 0) + { + AddLink(from, to); + } + } + if (rng.Next(6) == 0) + { + var s = rng.Next(n); + if (kinds[s] != "decision") + { + AddLink(s, s); + } + } + + sb.AppendLine("links:"); + foreach (var (f, t, label) in links) + { + sb.Append(CultureInfo.InvariantCulture, $" - {{ from: s{f}, to: s{t}"); + if (label is not null) + { + sb.Append(CultureInfo.InvariantCulture, $", label: \"{label}\""); + } + else if (rng.Next(4) == 0) + { + sb.Append(CultureInfo.InvariantCulture, $", label: \"{_flowVerbs[rng.Next(_flowVerbs.Length)].ToLowerInvariant()}\""); + } + + sb.AppendLine(" }"); + } + return sb.ToString(); + } + + private static readonly string[] _mindTitleWords = + [ + "Rendering", "Layout", "Routing", "Model", "Text", "Animation", "Packages", "Engine", + "Authoring", "Schema", "Determinism", "Pipeline", "Measure", "Theming", "Sequence", + "State", "Class", "Butterfly", "Accent", "Cycle", "Flow", "Packet", "Trail", "Caption", + ]; + + private static readonly string[] _mindItemWords = + [ + "model", "measure", "layout", "route", "svg", "animate", "coerce", "validate", + "defaults", "keyframes", "easing", "schedule", "compiler", "metrics", "obstacle", + "avoidance", "anchor", "spread", "virtual", "node", + ]; + + private static readonly string[] _mindSentences = + [ + "Same YAML, same SVG.", "Everything renders server side.", "No client JavaScript at all.", + "Colors are CSS variables only.", "The model fills every default.", + "Deterministic output every time.", "One continuous path per edge.", + ]; + + private static readonly string[] _mindAccents = ["primary", "info", "success", "warn", "danger", "neutral"]; + + /// + /// Deterministic type: mindmap generator: a nested topic tree (max rank 1..3, so 2–4 + /// levels deep counting the root), 1–5 children per topic, total topics capped near 25. Each + /// topic draws a random content mix — heading-only, heading+items (1–5), heading+body (1–2 + /// sentences), or items+body together — with occasional explicit accent: overrides and + /// occasional long titles / multi-word items to force card wrapping. A single first-level branch + /// (a lopsided butterfly) and a full five-branch fan are both reachable across the seed range. + /// Seed → YAML is pure; a failing case reproduces from its seed alone. + /// + public static string MindMapYaml(int seed) + { + var rng = new Random(seed); + var maxRank = 1 + rng.Next(3); // deepest rank 1..3 → 2..4 levels including the root + const int cap = 25; + var count = 1; // the root itself + + string Words(string[] bank, int min, int max) + { + var n = min + rng.Next(max - min + 1); + return string.Join(' ', Enumerable.Range(0, n).Select(_ => bank[rng.Next(bank.Length)])); + } + + // Usually 1–2 words; occasionally a long 3–4 word title to force wrapping. + string TopicTitle() => rng.Next(6) == 0 ? Words(_mindTitleWords, 3, 4) : Words(_mindTitleWords, 1, 2); + // Mostly a single word; occasionally a 2–3 word phrase (wrapping again). + string Item() => rng.Next(4) == 0 ? Words(_mindItemWords, 2, 3) : Words(_mindItemWords, 1, 1); + + var sb = new StringBuilder(); + sb.AppendLine("type: mindmap"); + sb.AppendLine(CultureInfo.InvariantCulture, $"meta: {{ title: \"{Words(_mindTitleWords, 1, 3)}\" }}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"root: \"{Words(_mindTitleWords, 1, 2)}\""); + sb.AppendLine("topics:"); + + // A topic list item begins at `dash` (its "- " marker); its sibling keys align two columns + // in; a nested `children:` sequence indents its own items four columns past `dash`. + void EmitTopic(string dash, int rank) + { + count++; + var key = dash + " "; + sb.Append(dash).Append(CultureInfo.InvariantCulture, $"- title: \"{TopicTitle()}\"").Append('\n'); + + if (rng.Next(5) == 0) + { + sb.Append(key).Append("accent: ").Append(_mindAccents[rng.Next(_mindAccents.Length)]).Append('\n'); + } + + var kind = rng.Next(4); // 0 heading-only · 1 items · 2 body · 3 items+body + if (kind is 1 or 3) + { + var items = string.Join(", ", Enumerable.Range(0, 1 + rng.Next(5)).Select(_ => $"\"{Item()}\"")); + sb.Append(key).Append("items: [").Append(items).Append("]\n"); + } + if (kind is 2 or 3) + { + var body = string.Join(' ', Enumerable.Range(0, 1 + rng.Next(2)).Select(_ => _mindSentences[rng.Next(_mindSentences.Length)])); + sb.Append(key).Append(CultureInfo.InvariantCulture, $"body: \"{body}\"").Append('\n'); + } + + // Recurse ~75% of the time while depth and the topic cap allow it, so both shallow leaf + // pills and deep subtrees appear, and the total stays near the cap. + if (rank < maxRank && count < cap && rng.Next(4) != 0) + { + var maxKids = Math.Min(5, cap - count); + sb.Append(key).Append("children:\n"); + var childDash = dash + " "; + for (var i = 0; i < maxKids && count < cap && (i == 0 || rng.Next(3) != 0); i++) + { + EmitTopic(childDash, rank + 1); + } + } + } + + var rootBranches = 1 + rng.Next(5); // 1..5 first-level branches + for (var i = 0; i < rootBranches && count < cap; i++) + { + EmitTopic(" ", 1); + } + + return sb.ToString(); + } } \ No newline at end of file diff --git a/tests/Beck.Tests/CleanLines/LineQuality.cs b/tests/Beck.Tests/CleanLines/LineQuality.cs index 1695829..3aee945 100644 --- a/tests/Beck.Tests/CleanLines/LineQuality.cs +++ b/tests/Beck.Tests/CleanLines/LineQuality.cs @@ -182,7 +182,7 @@ public static QualityReport Analyze((DiagramModel Model, LayoutResult Layout, IR } // ---- anchor fans ---- - var (faces, skewed, offCenter, fanViolations) = AnalyzeFaces(layout, edges, selfLoops); + var (faces, skewed, offCenter, fanViolations) = AnalyzeFaces(model, layout, edges, selfLoops); violations.AddRange(fanViolations); var nonLoop = edges.Count(e => !selfLoops.Contains(e.Edge.Id)); @@ -347,11 +347,18 @@ private static bool SegHitsRect(Point a, Point b, Rect rect, double inset = 4) /// consecutive gaps must be equal, and the spread must center on the face. /// private static (int Faces, int Skewed, int OffCenter, List Violations) AnalyzeFaces( - LayoutResult layout, IReadOnlyList edges, HashSet selfLoops) + DiagramModel model, LayoutResult layout, IReadOnlyList edges, HashSet selfLoops) { var violations = new List(); var buckets = new Dictionary<(string Node, Side Side), List>(); + // A parallelogram's left/right faces are slanted: the router nudges those anchors inward + // by skew/2 (see Svg/Artwork.cs ParallelogramSkew + Route/EdgePainter.cs paraSkew) so they + // land on the slant instead of the bbox edge. Top/bottom faces are unslanted — no nudge. + var paraNudge = model.Nodes + .Where(n => n.Shape is NodeShape.Parallelogram && layout.Nodes.ContainsKey(n.Id)) + .ToDictionary(n => n.Id, n => Math.Min(12, layout.Nodes[n.Id].H * 0.4) / 2); + void Record(string nodeId, Point p, string edgeId) { if (!layout.Nodes.TryGetValue(nodeId, out var r)) @@ -359,7 +366,8 @@ void Record(string nodeId, Point p, string edgeId) return; } - var side = FaceOf(r, p); + var nudge = paraNudge.GetValueOrDefault(nodeId); + var side = FaceOf(r, p, nudge); if (side is null) { violations.Add(new Defect("anchor-off-face", @@ -428,7 +436,11 @@ void Record(string nodeId, Point p, string edgeId) return (faces, skewed, offCenter, violations); } - private static Side? FaceOf(Rect r, Point p) + /// + /// A parallelogram's left/right anchors sit nudge px inward of the bbox edge, on the + /// shape's slant, rather than exactly on it (0 for every other shape). + /// + private static Side? FaceOf(Rect r, Point p, double nudge = 0) { const double Eps = 0.6; var inX = p.X >= r.X - Eps && p.X <= r.X + r.W + Eps; @@ -443,12 +455,12 @@ void Record(string nodeId, Point p, string edgeId) return Side.Bottom; } - if (inY && Math.Abs(p.X - r.X) < Eps) + if (inY && Math.Abs(p.X - (r.X + nudge)) < Eps) { return Side.Left; } - if (inY && Math.Abs(p.X - (r.X + r.W)) < Eps) + if (inY && Math.Abs(p.X - (r.X + r.W - nudge)) < Eps) { return Side.Right; } diff --git a/tests/Beck.Tests/CleanLines/MindmapQuality.cs b/tests/Beck.Tests/CleanLines/MindmapQuality.cs new file mode 100644 index 0000000..022f0c7 --- /dev/null +++ b/tests/Beck.Tests/CleanLines/MindmapQuality.cs @@ -0,0 +1,218 @@ +using Beck.Layout; +using Beck.Model; +using Beck.Route; +using Beck.Text; +using DiagramModel = Beck.Model.DiagramModel; + +namespace Beck.Tests.CleanLines; + +/// +/// Soft aesthetics for one routed mindmap, ratcheted against Goldens/cleanlines.json's +/// Mindmap key. Butterfly-specific, so nothing overlaps the architecture/flowchart baselines: +/// how much canvas each node costs, how long the mean parent→child thread runs, and how lopsided the +/// two halves are (heavier half total height ÷ lighter half — 1.0 is a perfect balance). +/// +internal sealed record MindmapMetrics(double AreaPerNode, double MeanEdgeLength, double HalfBalance); + +/// The scorecard for one routed mindmap: hard violations plus the soft . +internal sealed record MindmapReport(int Nodes, int Edges, IReadOnlyList Violations, MindmapMetrics Metrics); + +/// +/// The mindmap chaos monkey's analyzer. Mindmap edges are S-curves — a single cubic Bézier with only +/// two endpoint points — so the orthogonal checks (diagonal-on-step-round, +/// through-node, anchor fans) don't apply. This measures the butterfly's own invariants instead: +/// +/// +/// off-canvas — a node rect corner or a routed path point below −0.01. +/// node-overlap — any two node rects genuinely intersecting, across both halves. +/// tree-shape — edges = nodes − 1, and every edge runs from the parent's outward face +/// into the child's inward face, the two faces pointing at each other (verified against the actual +/// left/right x-order of each pair, never hardcoded). +/// root-centering — with both halves populated, the root's centre x sits strictly +/// between the left half's rightmost edge and the right half's leftmost edge. +/// +/// +internal static class MindmapQuality +{ + /// Genuine rect intersection must exceed this on both axes to count as an overlap. + private const double OverlapEps = 0.01; + + /// How close a path endpoint must sit to a face line to read as "on that face". + private const double FaceEps = 1.0; + + public static (DiagramModel Model, LayoutResult Layout, IReadOnlyList Edges) Route(string yaml) + { + var model = Validate.LoadDiagram(yaml); + var sizes = model.Nodes.ToDictionary(n => n.Id, n => CardSizer.Measure(n, InterMetricsMeasurer.Instance, mindMap: true)); + var layout = MindMapLayout.Compute(model, sizes); + return (model, layout, EdgePainter.RouteEdges(model, layout)); + } + + public static MindmapReport Analyze(string yaml) => Analyze(Route(yaml)); + + public static MindmapReport Analyze((DiagramModel Model, LayoutResult Layout, IReadOnlyList Edges) routed) + { + var (model, layout, edges) = routed; + var violations = new List(); + + // ---- off-canvas: node rects and routed path points ---- + foreach (var (id, r) in layout.Nodes) + { + if (r.X < -0.01 || r.Y < -0.01) + { + violations.Add(new Defect("off-canvas", $"node '{id}' at ({r.X:0.##}, {r.Y:0.##})")); + } + } + + foreach (var re in edges) + { + foreach (var p in re.Points) + { + if (p.X < -0.01 || p.Y < -0.01) + { + violations.Add(new Defect("off-canvas", $"{re.Edge.Id} passes ({p.X:0.##}, {p.Y:0.##})")); + } + } + } + + // ---- node-overlap: any pairwise rect intersection, across both halves ---- + var rects = layout.Nodes.OrderBy(kv => kv.Key).ToList(); + for (var i = 0; i < rects.Count; i++) + { + for (var j = i + 1; j < rects.Count; j++) + { + if (Intersects(rects[i].Value, rects[j].Value)) + { + violations.Add(new Defect("node-overlap", + $"'{rects[i].Key}' {Fmt(rects[i].Value)} overlaps '{rects[j].Key}' {Fmt(rects[j].Value)}")); + } + } + } + + // ---- tree-shape: edge count and per-edge face geometry ---- + if (edges.Count != layout.Nodes.Count - 1) + { + violations.Add(new Defect("tree-shape", + $"{edges.Count} edges over {layout.Nodes.Count} nodes (expected {layout.Nodes.Count - 1})")); + } + + foreach (var re in edges) + { + if (!layout.Nodes.TryGetValue(re.Edge.From, out var parent) + || !layout.Nodes.TryGetValue(re.Edge.To, out var child) + || re.Points.Count < 2) + { + continue; + } + + var from = re.Points[0]; + var to = re.Points[^1]; + var childOnRight = Center(child).X > Center(parent).X; + + // Right-half child: parent's RIGHT face → child's LEFT face. Left-half: mirrored. The two + // faces must point at each other, and each endpoint must land on its own face line within + // the node's vertical span. + var parentFaceX = childOnRight ? parent.X + parent.W : parent.X; + var childFaceX = childOnRight ? child.X : child.X + child.W; + + if (!OnVerticalFace(from, parentFaceX, parent)) + { + violations.Add(new Defect("tree-shape", + $"{re.Edge.Id} leaves ({from.X:0.##}, {from.Y:0.##}) — not on the {(childOnRight ? "right" : "left")} face of '{re.Edge.From}' {Fmt(parent)}")); + } + if (!OnVerticalFace(to, childFaceX, child)) + { + violations.Add(new Defect("tree-shape", + $"{re.Edge.Id} enters ({to.X:0.##}, {to.Y:0.##}) — not on the {(childOnRight ? "left" : "right")} face of '{re.Edge.To}' {Fmt(child)}")); + } + } + + // ---- root-centering: root strictly between the two halves ---- + var rootId = model.Nodes.FirstOrDefault(n => n.Rank == 0)?.Id ?? model.Nodes[0].Id; + if (layout.Nodes.TryGetValue(rootId, out var root)) + { + var rootCx = Center(root).X; + var left = layout.Nodes.Where(kv => kv.Key != rootId && Center(kv.Value).X < rootCx).Select(kv => kv.Value).ToList(); + var right = layout.Nodes.Where(kv => kv.Key != rootId && Center(kv.Value).X > rootCx).Select(kv => kv.Value).ToList(); + if (left.Count > 0 && right.Count > 0) + { + var leftMaxRight = left.Max(r => r.X + r.W); + var rightMinLeft = right.Min(r => r.X); + if (!(rootCx > leftMaxRight && rootCx < rightMinLeft)) + { + violations.Add(new Defect("root-centering", + $"root centre {rootCx:0.##} not strictly between left edge {leftMaxRight:0.##} and right edge {rightMinLeft:0.##}")); + } + } + } + + return new MindmapReport(layout.Nodes.Count, edges.Count, violations, Metrics(model, layout, edges, rootId)); + } + + private static MindmapMetrics Metrics( + DiagramModel model, LayoutResult layout, IReadOnlyList edges, string rootId) + { + var n = Math.Max(1, layout.Nodes.Count); + var areaPerNode = layout.Width * layout.Height / n; + + double edgeLen = 0; + var edgeN = 0; + foreach (var re in edges) + { + if (re.Points.Count < 2) + { + continue; + } + + var a = re.Points[0]; + var b = re.Points[^1]; + edgeLen += Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y)); + edgeN++; + } + var meanEdge = edgeN == 0 ? 0 : edgeLen / edgeN; + + double leftH = 0, rightH = 0; + if (layout.Nodes.TryGetValue(rootId, out var root)) + { + var rootCx = Center(root).X; + foreach (var (id, r) in layout.Nodes) + { + if (id == rootId) + { + continue; + } + + if (Center(r).X < rootCx) + { + leftH += r.H; + } + else + { + rightH += r.H; + } + } + } + var heavier = Math.Max(leftH, rightH); + var lighter = Math.Min(leftH, rightH); + // A single-branch (one-sided) butterfly has nothing to balance — that lopsidedness is inherent + // to the layout, not a defect — so it reads as neutral (1.0). Only when both halves carry + // weight does the ratio measure how evenly the balancer split them. + var balance = leftH <= 0.01 || rightH <= 0.01 ? 1.0 : heavier / lighter; + + return new MindmapMetrics(areaPerNode, meanEdge, balance); + } + + private static Point Center(Rect r) => new(r.X + r.W / 2, r.Y + r.H / 2); + + private static bool Intersects(Rect a, Rect b) + { + var ox = Math.Min(a.X + a.W, b.X + b.W) - Math.Max(a.X, b.X); + var oy = Math.Min(a.Y + a.H, b.Y + b.H) - Math.Max(a.Y, b.Y); + return ox > OverlapEps && oy > OverlapEps; + } + + private static bool OnVerticalFace(Point p, double faceX, Rect r) => + Math.Abs(p.X - faceX) <= FaceEps && p.Y >= r.Y - FaceEps && p.Y <= r.Y + r.H + FaceEps; + + private static string Fmt(Rect r) => $"[{r.X:0.##},{r.Y:0.##} {r.W:0.##}×{r.H:0.##}]"; +} diff --git a/tests/Beck.Tests/Corpus/flowchart-branchy.yaml b/tests/Beck.Tests/Corpus/flowchart-branchy.yaml new file mode 100644 index 0000000..2901151 --- /dev/null +++ b/tests/Beck.Tests/Corpus/flowchart-branchy.yaml @@ -0,0 +1,24 @@ +type: flowchart +meta: + title: Request Handling + direction: TB +steps: + - { id: begin, kind: start } + - { id: read, text: Read request, kind: io } + - { id: validate, text: Validate input } + - { id: check, text: Valid?, kind: decision } + - { id: retry, text: Fix input } + - { id: process, text: Process request } + - { id: notify, text: Notify caller, kind: io } + - { id: reject, text: Give up, kind: terminator } + - { id: finish, kind: end } +links: + - { from: begin, to: read } + - { from: read, to: validate } + - { from: validate, to: check } + - { from: check, to: process, label: "yes" } + - { from: check, to: retry, label: "no" } + - { from: retry, to: validate } + - { from: retry, to: reject, label: "give up" } + - { from: process, to: notify } + - { from: notify, to: finish } diff --git a/tests/Beck.Tests/Corpus/flowchart-simple.yaml b/tests/Beck.Tests/Corpus/flowchart-simple.yaml new file mode 100644 index 0000000..61f3a79 --- /dev/null +++ b/tests/Beck.Tests/Corpus/flowchart-simple.yaml @@ -0,0 +1,11 @@ +type: flowchart +meta: + title: Deploy Pipeline + direction: TB +steps: + - { id: begin, kind: start } + - { id: build, text: Build artifact } + - { id: finish, kind: end } +links: + - { from: begin, to: build } + - { from: build, to: finish } diff --git a/tests/Beck.Tests/Corpus/mindmap-kitchen.yaml b/tests/Beck.Tests/Corpus/mindmap-kitchen.yaml new file mode 100644 index 0000000..cec20c7 --- /dev/null +++ b/tests/Beck.Tests/Corpus/mindmap-kitchen.yaml @@ -0,0 +1,39 @@ +type: mindmap +meta: + title: Beck Architecture +root: + title: Beck + subtitle: server-side diagrams +topics: + - title: Rendering + accent: info + children: + - title: Pipeline + items: [Model, Text, Layout, Route, Svg, Animate] + - title: Determinism + body: > + Same YAML and the same options always produce + byte-identical SVG, ids from a content hash. + - title: Diagram Types + children: + - title: Architecture + - title: Sequence + - title: State + - title: Class + - title: Packages + children: + - title: Beck + items: [engine, authoring] + - title: Beck.Skia + children: + - title: HarfBuzz shaping + - title: Font metrics + - title: Authoring + accent: success + children: + - title: DiagramBuilder + - title: YamlWriter + - title: Theming + children: + - title: CSS variables + - title: Dark mode diff --git a/tests/Beck.Tests/Corpus/mindmap-simple.yaml b/tests/Beck.Tests/Corpus/mindmap-simple.yaml new file mode 100644 index 0000000..c40ef39 --- /dev/null +++ b/tests/Beck.Tests/Corpus/mindmap-simple.yaml @@ -0,0 +1,13 @@ +type: mindmap +meta: + title: Beck Overview +root: Beck +topics: + - title: Rendering + children: + - title: Layout + - title: Routing + - title: Packages + children: + - title: Engine + - title: Skia diff --git a/tests/Beck.Tests/Corpus/mindmap-status.yaml b/tests/Beck.Tests/Corpus/mindmap-status.yaml new file mode 100644 index 0000000..3de83cb --- /dev/null +++ b/tests/Beck.Tests/Corpus/mindmap-status.yaml @@ -0,0 +1,32 @@ +type: mindmap +meta: + title: Platform Redesign +root: + title: Platform Redesign + subtitle: Q3 initiative + icon: model +topics: + - title: Research + icon: search + status: complete + children: + - title: User interviews + - title: Analytics audit + - title: Design + icon: browser + children: + - title: Design tokens + - title: Component library + - title: Prototype + - title: Engineering + icon: code + status: in progress + children: + - title: API migration + - title: Frontend rebuild + - title: Launch + icon: cloud + ghost: true + children: + - title: Beta program + - title: Marketing site diff --git a/tests/Beck.Tests/DiamondParallelogramTests.cs b/tests/Beck.Tests/DiamondParallelogramTests.cs index 0389986..8cc0f8d 100644 --- a/tests/Beck.Tests/DiamondParallelogramTests.cs +++ b/tests/Beck.Tests/DiamondParallelogramTests.cs @@ -140,6 +140,59 @@ public void Diamond_FanIn_AnchorsLandExactlyOnFaceMidpoints_NotSpread() } } + // ---- flowchart decision: edges spread across distinct diamond vertices ---- + + [Fact] + public void Flowchart_DecisionEdges_UseDistinctVertices_NoSharedTrunk() + { + // A decision with two same-rank-below targets plus a return edge back into it. Point-anchoring + // once landed all three on the bottom vertex, sharing a trunk that read as one line forking. + // The flowchart reassignment must give them distinct vertices (yes → one side, no → the other, + // return → the free vertex), so no two edges leave/enter the diamond on a shared collinear run. + const string yaml = """ + type: flowchart + meta: { direction: TB } + steps: + - { id: check, text: Valid?, kind: decision } + - { id: process, text: Process } + - { id: retry, text: Fix input } + links: + - { from: check, to: process, label: "yes" } + - { from: check, to: retry, label: "no" } + - { from: retry, to: check } + """; + + var (_, layout, edges) = LineQuality.Route(yaml); + var dia = layout.Nodes["check"]; + var cx = dia.X + dia.W / 2; + var cy = dia.Y + dia.H / 2; + + // Each edge's anchor on the diamond (start if it leaves check, end if it enters). + var anchors = edges + .Where(e => e.Edge.From == "check" || e.Edge.To == "check") + .Select(e => e.Edge.From == "check" ? e.Points[0] : e.Points[^1]) + .ToList(); + Assert.Equal(3, anchors.Count); + + // Three free vertices, three edges → three distinct anchor points (no shared vertex/trunk). + var distinct = anchors.Select(p => (Math.Round(p.X, 1), Math.Round(p.Y, 1))).Distinct().Count(); + Assert.Equal(3, distinct); + + // Every anchor sits exactly on one of the four bbox face midpoints (a diamond vertex), and + // the two out-branches land on opposite side vertices (Left/Right), not the same bottom point. + bool IsVertex(Point p) => + (Near(p.X, cx) && (Near(p.Y, dia.Y) || Near(p.Y, dia.Y + dia.H))) + || (Near(p.Y, cy) && (Near(p.X, dia.X) || Near(p.X, dia.X + dia.W))); + Assert.All(anchors, p => Assert.True(IsVertex(p), $"anchor ({p.X:0.#},{p.Y:0.#}) is not a diamond vertex")); + + var yes = edges.Single(e => e.Edge.To == "process").Points[0]; + var no = edges.Single(e => e.Edge.To == "retry").Points[0]; + Assert.True(Near(yes.Y, cy) && Near(no.Y, cy), "both branches should leave a side (Left/Right) vertex"); + Assert.False(Near(yes.X, no.X), "yes- and no-branch must leave DIFFERENT side vertices, not one trunk"); + } + + private static bool Near(double a, double b) => Math.Abs(a - b) < 0.5; + // ---- render smoke ---- [Fact] diff --git a/tests/Beck.Tests/FlowchartBuilderTests.cs b/tests/Beck.Tests/FlowchartBuilderTests.cs new file mode 100644 index 0000000..6507bad --- /dev/null +++ b/tests/Beck.Tests/FlowchartBuilderTests.cs @@ -0,0 +1,208 @@ +using Beck.Authoring; +using Beck.Model; +using Xunit; +using AccentToken = Beck.Authoring.AccentToken; +using Direction = Beck.Authoring.Direction; +using FitMode = Beck.Authoring.FitMode; + +namespace Beck.Tests; + +/// +/// The authoring side of the type: flowchart schema contract +/// (): fluent-built YAML must parse via +/// into the same model shapes/edge props the +/// hand-written YAML in exercises. +/// +public sealed class FlowchartBuilderTests +{ + [Fact] + public void AllStepKinds_RoundTripToExpectedShapes() + { + var yaml = new FlowchartDiagramBuilder("Kinds") + .Process("p", "Process step") + .Decision("d", "Decision step") + .Terminator("t", "Terminator step") + .Io("i", "IO step") + .Start("begin") + .End("finish") + .Link("begin", "p") + .Link("p", "d") + .Link("d", "t", "yes") + .Link("d", "i", "no") + .Link("t", "finish") + .Link("i", "finish") + .ToYaml(); + + var model = Validate.LoadDiagram(yaml); + + Assert.Equal(NodeShape.Card, model.Nodes.Single(n => n.Id == "p").Shape); + Assert.Equal(NodeShape.Diamond, model.Nodes.Single(n => n.Id == "d").Shape); + Assert.Equal(NodeShape.Pill, model.Nodes.Single(n => n.Id == "t").Shape); + Assert.Equal(NodeShape.Parallelogram, model.Nodes.Single(n => n.Id == "i").Shape); + Assert.Equal(NodeShape.Start, model.Nodes.Single(n => n.Id == "begin").Shape); + Assert.Equal(NodeShape.End, model.Nodes.Single(n => n.Id == "finish").Shape); + } + + [Fact] + public void DecisionAndIoSteps_RoundTripToDiamondAndParallelogram() + { + var yaml = new FlowchartDiagramBuilder() + .Decision("check", "Valid?") + .Io("read", "Read input") + .Link("read", "check") + .ToYaml(); + + var model = Validate.LoadDiagram(yaml); + Assert.Equal(NodeShape.Diamond, model.Nodes.Single(n => n.Id == "check").Shape); + Assert.Equal(NodeShape.Parallelogram, model.Nodes.Single(n => n.Id == "read").Shape); + } + + [Fact] + public void LinkLabel_PassesThroughToTheEdge() + { + var yaml = new FlowchartDiagramBuilder() + .Decision("check", "Valid?") + .Process("ok", "Continue") + .Process("bad", "Reject") + .Link("check", "ok", "yes") + .Link("check", "bad", "no") + .ToYaml(); + + var model = Validate.LoadDiagram(yaml); + Assert.Equal("yes", model.Edges.Single(e => e.To == "ok").Label); + Assert.Equal("no", model.Edges.Single(e => e.To == "bad").Label); + } + + [Fact] + public void UndeclaredStepReferencedByLink_MaterializesAsProcessCard() + { + var yaml = new FlowchartDiagramBuilder() + .Process("a", "A") + .Link("a", "b") + .ToYaml(); + + var model = Validate.LoadDiagram(yaml); + Assert.Equal(NodeShape.Card, model.Nodes.Single(n => n.Id == "b").Shape); + } + + [Fact] + public void PseudoStepReferencedFromLinks_MaterializesStartAndEnd() + { + var yaml = new FlowchartDiagramBuilder() + .Process("work", "Do work") + .Link(FlowchartDiagramBuilder.Pseudo, "work") + .Link("work", FlowchartDiagramBuilder.Pseudo) + .ToYaml(); + + var model = Validate.LoadDiagram(yaml); + Assert.Contains(model.Nodes, n => n.Id == "#start" && n.Shape == NodeShape.Start); + Assert.Contains(model.Nodes, n => n.Id == "#end" && n.Shape == NodeShape.End); + Assert.Equal(2, model.Edges.Count); + } + + [Fact] + public void StepBuilder_SetsSubtitleAccentAndPosition() + { + var yaml = new FlowchartDiagramBuilder() + .Step("a", s => s + .Text("Step A") + .Kind(StepKind.Process) + .Subtitle("a subtitle") + .Accent(AccentToken.Success) + .Width(180) + .Rank(1) + .Order(2)) + .Link("a", "b") + .ToYaml(); + + Assert.Contains("subtitle: a subtitle", yaml); + Assert.Contains("accent:", yaml); + Assert.Contains("width: 180", yaml); + Assert.Contains("rank: 1", yaml); + Assert.Contains("order: 2", yaml); + + var model = Validate.LoadDiagram(yaml); + var node = model.Nodes.Single(n => n.Id == "a"); + Assert.Equal("Step A", node.Title); + Assert.Equal("a subtitle", node.Subtitle); + } + + [Fact] + public void LinkBuilder_SetsStyleColorAndNote() + { + var yaml = new FlowchartDiagramBuilder() + .Process("a") + .Process("b") + .Link("a", "b", configure: l => l + .Label("go") + .Style(Beck.Authoring.EdgeStyle.Dashed) + .Color(AccentToken.Danger) + .Note("narration text")) + .ToYaml(); + + var model = Validate.LoadDiagram(yaml); + var edge = model.Edges.Single(); + Assert.Equal("go", edge.Label); + Assert.Equal(Beck.Model.EdgeStyle.Dashed, edge.Style); + } + + [Fact] + public void MetaOptions_EmitAndRoundTrip() + { + var yaml = new FlowchartDiagramBuilder("My Flowchart") + .Subtitle("a subtitle") + .Direction(Direction.Lr) + .Theme(Beck.Authoring.ThemeMode.Dark) + .Animate(false) + .Loop(false) + .Fit(FitMode.Scroll) + .Process("a") + .Process("b") + .Link("a", "b") + .ToYaml(); + + Assert.Contains("type: flowchart", yaml); + Assert.Contains("title: My Flowchart", yaml); + Assert.Contains("direction: LR", yaml); + + var model = Validate.LoadDiagram(yaml); + Assert.Equal("My Flowchart", model.Meta.Title); + Assert.Equal("a subtitle", model.Meta.Subtitle); + } + + [Fact] + public void ScriptedFlow_Emits() + { + var yaml = new FlowchartDiagramBuilder() + .Process("a") + .Process("b") + .Link("a", "b") + .Flow(f => f.Packet("a", "b", label: "go")) + .ToYaml(); + + Assert.Contains("flow:", yaml); + Assert.Contains("packet:", yaml); + + // Flow parses cleanly against the materialized step ids. + Validate.LoadDiagram(yaml); + } + + [Fact] + public void Empty_Throws() + { + Assert.Throws(() => new FlowchartDiagramBuilder().ToYaml()); + } + + [Fact] + public void ToFence_WrapsInBeckCodeBlock() + { + var fence = new FlowchartDiagramBuilder() + .Process("a") + .Process("b") + .Link("a", "b") + .ToFence(); + + Assert.StartsWith("```beck\n", fence); + Assert.EndsWith("```\n", fence); + } +} diff --git a/tests/Beck.Tests/FlowchartTests.cs b/tests/Beck.Tests/FlowchartTests.cs new file mode 100644 index 0000000..273f0ae --- /dev/null +++ b/tests/Beck.Tests/FlowchartTests.cs @@ -0,0 +1,145 @@ +using Beck.Model; +using Xunit; + +namespace Beck.Tests; + +/// +/// Model-shape unit tests for type: flowchart () plus a +/// byte-identity determinism check over the two new corpus files, mirroring the invariant that +/// same YAML + same options always renders byte-identical SVG. +/// +public sealed class FlowchartTests +{ + private static readonly string _corpusDir = Path.Combine(AppContext.BaseDirectory, "Corpus"); + + [Theory] + [InlineData("process", "Card")] + [InlineData("decision", "Diamond")] + [InlineData("terminator", "Pill")] + [InlineData("io", "Parallelogram")] + [InlineData("start", "Start")] + [InlineData("end", "End")] + public void KindMapsToExpectedShape(string kind, string expectedShape) + { + var yaml = "type: flowchart\nsteps:\n - { id: a, kind: " + kind + " }\nlinks: []\n"; + var model = Validate.LoadDiagram(yaml); + Assert.Equal(expectedShape, model.Nodes.Single(n => n.Id == "a").Shape.ToString()); + } + + [Fact] + public void PseudoStepReferencedFromLinks_MaterializesStartAndEnd() + { + const string yaml = """ + type: flowchart + steps: + - { id: work, text: Do work } + links: + - { from: "[*]", to: work } + - { from: work, to: "[*]" } + """; + var model = Validate.LoadDiagram(yaml); + Assert.Contains(model.Nodes, n => n.Id == "#start" && n.Shape == NodeShape.Start); + Assert.Contains(model.Nodes, n => n.Id == "#end" && n.Shape == NodeShape.End); + Assert.Equal(2, model.Edges.Count); + } + + [Fact] + public void PseudoStep_BindsToDeclaredStartAndEnd_WhenExactlyOne() + { + // A declared `kind: start` / `kind: end` step wired via "[*]" binds to that step rather than + // spawning a floating #start/#end dot beside it. + const string yaml = """ + type: flowchart + steps: + - { id: begin, kind: start } + - { id: process, text: Process order } + - { id: finish, kind: end } + links: + - { from: "[*]", to: process } + - { from: process, to: "[*]" } + """; + var model = Validate.LoadDiagram(yaml); + Assert.DoesNotContain(model.Nodes, n => n.Id is "#start" or "#end"); + Assert.Equal(3, model.Nodes.Count); + Assert.Equal("begin", model.Edges[0].From); + Assert.Equal("finish", model.Edges[1].To); + Assert.Contains(model.Nodes, n => n.Id == "begin" && n.Shape == NodeShape.Start); + Assert.Contains(model.Nodes, n => n.Id == "finish" && n.Shape == NodeShape.End); + } + + [Fact] + public void PseudoStep_FallsBackToPseudoNode_WhenNoDeclaredStartEnd() + { + // No declared start/end of the relevant kind → the anonymous pseudo-node is materialized. + const string yaml = """ + type: flowchart + steps: + - { id: work, text: Do work } + links: + - { from: "[*]", to: work } + - { from: work, to: "[*]" } + """; + var model = Validate.LoadDiagram(yaml); + Assert.Contains(model.Nodes, n => n.Id == "#start" && n.Shape == NodeShape.Start); + Assert.Contains(model.Nodes, n => n.Id == "#end" && n.Shape == NodeShape.End); + } + + [Fact] + public void PseudoStep_Throws_WhenMultipleDeclaredStarts() + { + const string yaml = """ + type: flowchart + steps: + - { id: begin1, kind: start } + - { id: begin2, kind: start } + - { id: work } + links: + - { from: "[*]", to: work } + """; + var ex = Assert.Throws(() => Validate.LoadDiagram(yaml)); + Assert.Contains("ambiguous", ex.Message); + Assert.Contains("start", ex.Message); + } + + [Fact] + public void LinkLabelPassesThroughToTheEdge() + { + const string yaml = """ + type: flowchart + steps: + - { id: a } + - { id: b } + - { id: c } + links: + - { from: a, to: b, label: "yes" } + - { from: a, to: c, label: "no" } + """; + var model = Validate.LoadDiagram(yaml); + Assert.Equal("yes", model.Edges.Single(e => e.To == "b").Label); + Assert.Equal("no", model.Edges.Single(e => e.To == "c").Label); + } + + [Fact] + public void UnknownKind_Throws() + { + const string yaml = """ + type: flowchart + steps: + - { id: a, kind: bogus } + links: [] + """; + var ex = Assert.Throws(() => Validate.LoadDiagram(yaml)); + Assert.Contains("kind", ex.Message); + } + + [Theory] + [InlineData("flowchart-simple")] + [InlineData("flowchart-branchy")] + public void Render_IsDeterministic_AcrossTwoRuns(string name) + { + var yaml = File.ReadAllText(Path.Combine(_corpusDir, name + ".yaml")); + var first = BeckSvg.Render(yaml); + var second = BeckSvg.Render(yaml); + Assert.Equal(first, second); + } +} diff --git a/tests/Beck.Tests/Goldens/cleanlines.json b/tests/Beck.Tests/Goldens/cleanlines.json index 8e3cfab..1b8021e 100644 --- a/tests/Beck.Tests/Goldens/cleanlines.json +++ b/tests/Beck.Tests/Goldens/cleanlines.json @@ -1,8 +1,23 @@ { - "StraightRate": 0.2097946690473154, - "MicroJogsPerEdge": 0.01243463843897462, - "MergedRunsPerEdge": 0.09992347914806785, - "SkewedFaceRate": 0.08127328140873688, - "BendsPerEdge": 1.9304297921183522, - "TightEdgeRate": 0.08340772860604515 + "Architecture": { + "StraightRate": 0.2097946690473154, + "MicroJogsPerEdge": 0.01243463843897462, + "MergedRunsPerEdge": 0.09992347914806785, + "SkewedFaceRate": 0.08127328140873688, + "BendsPerEdge": 1.9304297921183522, + "TightEdgeRate": 0.08340772860604515 + }, + "Flowchart": { + "StraightRate": 0.11445983379501386, + "MicroJogsPerEdge": 0.015401662049861495, + "MergedRunsPerEdge": 0.6396675900277008, + "SkewedFaceRate": 0.07825225621835791, + "BendsPerEdge": 1.935567867036011, + "TightEdgeRate": 0.05174515235457064 + }, + "Mindmap": { + "AreaPerNode": 62347.382777808845, + "MeanEdgeLength": 127.99757117486371, + "HalfBalance": 1.6692628252996586 + } } \ No newline at end of file diff --git a/tests/Beck.Tests/Goldens/model/flowchart-branchy.model.json b/tests/Beck.Tests/Goldens/model/flowchart-branchy.model.json new file mode 100644 index 0000000..1946f2f --- /dev/null +++ b/tests/Beck.Tests/Goldens/model/flowchart-branchy.model.json @@ -0,0 +1 @@ +{"edges":[{"arrow":"end","color":"var(--beck-edge)","curve":"step-round","from":"begin","id":"begin->read#0","kind":"control","reply":false,"style":"solid","to":"read"},{"arrow":"end","color":"var(--beck-edge)","curve":"step-round","from":"read","id":"read->validate#1","kind":"control","reply":false,"style":"solid","to":"validate"},{"arrow":"end","color":"var(--beck-edge)","curve":"step-round","from":"validate","id":"validate->check#2","kind":"control","reply":false,"style":"solid","to":"check"},{"arrow":"end","color":"var(--beck-edge)","curve":"step-round","from":"check","id":"check->process#3","kind":"control","label":"yes","reply":false,"style":"solid","to":"process"},{"arrow":"end","color":"var(--beck-edge)","curve":"step-round","from":"check","id":"check->retry#4","kind":"control","label":"no","reply":false,"style":"solid","to":"retry"},{"arrow":"end","color":"var(--beck-edge)","curve":"step-round","from":"retry","id":"retry->validate#5","kind":"control","reply":false,"style":"solid","to":"validate"},{"arrow":"end","color":"var(--beck-edge)","curve":"step-round","from":"retry","id":"retry->reject#6","kind":"control","label":"give up","reply":false,"style":"solid","to":"reject"},{"arrow":"end","color":"var(--beck-edge)","curve":"step-round","from":"process","id":"process->notify#7","kind":"control","reply":false,"style":"solid","to":"notify"},{"arrow":"end","color":"var(--beck-edge)","curve":"step-round","from":"notify","id":"notify->finish#8","kind":"control","reply":false,"style":"solid","to":"finish"}],"flow":{"derived":true,"repeat":-1,"repeatDelay":1.2,"steps":[{"label":"begin","type":"phase"},{"from":"begin","to":"read","type":"packet"},{"label":"read","type":"phase"},{"from":"read","to":"validate","type":"packet"},{"label":"validate","type":"phase"},{"from":"validate","to":"check","type":"packet"},{"label":"check","type":"phase"},{"from":"check","to":"process","type":"packet"},{"from":"check","to":"retry","type":"packet"},{"label":"process","type":"phase"},{"from":"process","to":"notify","type":"packet"},{"label":"retry","type":"phase"},{"from":"retry","to":"validate","type":"packet"},{"from":"retry","to":"reject","type":"packet"},{"label":"notify","type":"phase"},{"from":"notify","to":"finish","type":"packet"},{"seconds":1,"type":"wait"},{"type":"reset"}]},"groups":[],"meta":{"animate":true,"direction":"TB","fit":"shrink","loop":true,"narration":{"enabled":true,"min":1.4,"pad":0.5,"wpm":170},"spacing":{"cornerRadius":16,"node":72,"rank":130},"theme":"auto","title":"Request Handling","type":"flowchart"},"nodes":[{"accent":"var(--beck-neutral)","fields":[],"id":"begin","kind":"service","methods":[],"shape":"start","title":"begin","variant":"solid"},{"accent":"var(--beck-neutral)","fields":[],"id":"read","kind":"service","methods":[],"shape":"parallelogram","title":"Read request","variant":"solid"},{"accent":"var(--beck-neutral)","fields":[],"id":"validate","kind":"service","methods":[],"shape":"card","title":"Validate input","variant":"solid"},{"accent":"var(--beck-neutral)","fields":[],"id":"check","kind":"service","methods":[],"shape":"diamond","title":"Valid?","variant":"solid"},{"accent":"var(--beck-neutral)","fields":[],"id":"process","kind":"service","methods":[],"shape":"card","title":"Process request","variant":"solid"},{"accent":"var(--beck-neutral)","fields":[],"id":"retry","kind":"service","methods":[],"shape":"card","title":"Fix input","variant":"solid"},{"accent":"var(--beck-neutral)","fields":[],"id":"reject","kind":"service","methods":[],"shape":"pill","title":"Give up","variant":"solid"},{"accent":"var(--beck-neutral)","fields":[],"id":"notify","kind":"service","methods":[],"shape":"parallelogram","title":"Notify caller","variant":"solid"},{"accent":"var(--beck-neutral)","fields":[],"id":"finish","kind":"service","methods":[],"shape":"end","title":"finish","variant":"solid"}],"sections":[]} \ No newline at end of file diff --git a/tests/Beck.Tests/Goldens/model/flowchart-simple.model.json b/tests/Beck.Tests/Goldens/model/flowchart-simple.model.json new file mode 100644 index 0000000..05ee467 --- /dev/null +++ b/tests/Beck.Tests/Goldens/model/flowchart-simple.model.json @@ -0,0 +1 @@ +{"edges":[{"arrow":"end","color":"var(--beck-edge)","curve":"step-round","from":"begin","id":"begin->build#0","kind":"control","reply":false,"style":"solid","to":"build"},{"arrow":"end","color":"var(--beck-edge)","curve":"step-round","from":"build","id":"build->finish#1","kind":"control","reply":false,"style":"solid","to":"finish"}],"flow":{"derived":true,"repeat":-1,"repeatDelay":1.2,"steps":[{"label":"begin","type":"phase"},{"from":"begin","to":"build","type":"packet"},{"label":"build","type":"phase"},{"from":"build","to":"finish","type":"packet"},{"seconds":1,"type":"wait"},{"type":"reset"}]},"groups":[],"meta":{"animate":true,"direction":"TB","fit":"shrink","loop":true,"narration":{"enabled":true,"min":1.4,"pad":0.5,"wpm":170},"spacing":{"cornerRadius":16,"node":72,"rank":130},"theme":"auto","title":"Deploy Pipeline","type":"flowchart"},"nodes":[{"accent":"var(--beck-neutral)","fields":[],"id":"begin","kind":"service","methods":[],"shape":"start","title":"begin","variant":"solid"},{"accent":"var(--beck-neutral)","fields":[],"id":"build","kind":"service","methods":[],"shape":"card","title":"Build artifact","variant":"solid"},{"accent":"var(--beck-neutral)","fields":[],"id":"finish","kind":"service","methods":[],"shape":"end","title":"finish","variant":"solid"}],"sections":[]} \ No newline at end of file diff --git a/tests/Beck.Tests/Goldens/model/mindmap-kitchen.model.json b/tests/Beck.Tests/Goldens/model/mindmap-kitchen.model.json new file mode 100644 index 0000000..5946a56 --- /dev/null +++ b/tests/Beck.Tests/Goldens/model/mindmap-kitchen.model.json @@ -0,0 +1 @@ +{"edges":[{"arrow":"none","color":"color-mix(in srgb, var(--beck-info) 55%, var(--beck-edge))","curve":"s","from":"root","id":"root->root-0#0","kind":"data","reply":false,"style":"solid","to":"root-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-info) 55%, var(--beck-edge))","curve":"s","from":"root-0","id":"root-0->root-0-0#1","kind":"data","reply":false,"style":"solid","to":"root-0-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-info) 55%, var(--beck-edge))","curve":"s","from":"root-0","id":"root-0->root-0-1#2","kind":"data","reply":false,"style":"solid","to":"root-0-1"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-primary) 55%, var(--beck-edge))","curve":"s","from":"root","id":"root->root-1#3","kind":"data","reply":false,"style":"solid","to":"root-1"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-primary) 55%, var(--beck-edge))","curve":"s","from":"root-1","id":"root-1->root-1-0#4","kind":"data","reply":false,"style":"solid","to":"root-1-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-primary) 55%, var(--beck-edge))","curve":"s","from":"root-1","id":"root-1->root-1-1#5","kind":"data","reply":false,"style":"solid","to":"root-1-1"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-primary) 55%, var(--beck-edge))","curve":"s","from":"root-1","id":"root-1->root-1-2#6","kind":"data","reply":false,"style":"solid","to":"root-1-2"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-primary) 55%, var(--beck-edge))","curve":"s","from":"root-1","id":"root-1->root-1-3#7","kind":"data","reply":false,"style":"solid","to":"root-1-3"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-success) 55%, var(--beck-edge))","curve":"s","from":"root","id":"root->root-2#8","kind":"data","reply":false,"style":"solid","to":"root-2"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-success) 55%, var(--beck-edge))","curve":"s","from":"root-2","id":"root-2->root-2-0#9","kind":"data","reply":false,"style":"solid","to":"root-2-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-success) 55%, var(--beck-edge))","curve":"s","from":"root-2","id":"root-2->root-2-1#10","kind":"data","reply":false,"style":"solid","to":"root-2-1"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-success) 55%, var(--beck-edge))","curve":"s","from":"root-2-1","id":"root-2-1->root-2-1-0#11","kind":"data","reply":false,"style":"solid","to":"root-2-1-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-success) 55%, var(--beck-edge))","curve":"s","from":"root-2-1","id":"root-2-1->root-2-1-1#12","kind":"data","reply":false,"style":"solid","to":"root-2-1-1"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-success) 55%, var(--beck-edge))","curve":"s","from":"root","id":"root->root-3#13","kind":"data","reply":false,"style":"solid","to":"root-3"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-success) 55%, var(--beck-edge))","curve":"s","from":"root-3","id":"root-3->root-3-0#14","kind":"data","reply":false,"style":"solid","to":"root-3-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-success) 55%, var(--beck-edge))","curve":"s","from":"root-3","id":"root-3->root-3-1#15","kind":"data","reply":false,"style":"solid","to":"root-3-1"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-danger) 55%, var(--beck-edge))","curve":"s","from":"root","id":"root->root-4#16","kind":"data","reply":false,"style":"solid","to":"root-4"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-danger) 55%, var(--beck-edge))","curve":"s","from":"root-4","id":"root-4->root-4-0#17","kind":"data","reply":false,"style":"solid","to":"root-4-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-danger) 55%, var(--beck-edge))","curve":"s","from":"root-4","id":"root-4->root-4-1#18","kind":"data","reply":false,"style":"solid","to":"root-4-1"}],"flow":{"derived":true,"repeat":-1,"repeatDelay":1.2,"steps":[{"label":"root","type":"phase"},{"from":"root","to":"root-0","type":"packet"},{"from":"root","to":"root-1","type":"packet"},{"from":"root","to":"root-2","type":"packet"},{"from":"root","to":"root-3","type":"packet"},{"from":"root","to":"root-4","type":"packet"},{"label":"root-0","type":"phase"},{"from":"root-0","to":"root-0-0","type":"packet"},{"from":"root-0","to":"root-0-1","type":"packet"},{"label":"root-1","type":"phase"},{"from":"root-1","to":"root-1-0","type":"packet"},{"from":"root-1","to":"root-1-1","type":"packet"},{"from":"root-1","to":"root-1-2","type":"packet"},{"from":"root-1","to":"root-1-3","type":"packet"},{"label":"root-2","type":"phase"},{"from":"root-2","to":"root-2-0","type":"packet"},{"from":"root-2","to":"root-2-1","type":"packet"},{"label":"root-3","type":"phase"},{"from":"root-3","to":"root-3-0","type":"packet"},{"from":"root-3","to":"root-3-1","type":"packet"},{"label":"root-4","type":"phase"},{"from":"root-4","to":"root-4-0","type":"packet"},{"from":"root-4","to":"root-4-1","type":"packet"},{"label":"root-2-1","type":"phase"},{"from":"root-2-1","to":"root-2-1-0","type":"packet"},{"from":"root-2-1","to":"root-2-1-1","type":"packet"},{"seconds":1,"type":"wait"},{"type":"reset"}]},"groups":[],"meta":{"animate":false,"direction":"LR","fit":"shrink","loop":true,"narration":{"enabled":true,"min":1.4,"pad":0.5,"wpm":170},"spacing":{"cornerRadius":16,"node":20,"rank":70},"theme":"auto","title":"Beck Architecture","type":"mindmap"},"nodes":[{"accent":"var(--beck-primary)","fields":[],"id":"root","kind":"service","methods":[],"order":0,"rank":0,"shape":"card","subtitle":"server-side diagrams","title":"Beck","variant":"solid"},{"accent":"var(--beck-info)","fields":[],"id":"root-0","kind":"service","methods":[],"order":1,"rank":1,"shape":"card","title":"Rendering","variant":"solid"},{"accent":"var(--beck-info)","fields":[],"id":"root-0-0","items":["Model","Text","Layout","Route","Svg","Animate"],"kind":"service","methods":[],"order":2,"rank":2,"shape":"card","title":"Pipeline","variant":"solid"},{"accent":"var(--beck-info)","body":"Same YAML and the same options always produce byte-identical SVG, ids from a content hash.\n","fields":[],"id":"root-0-1","kind":"service","methods":[],"order":3,"rank":2,"shape":"card","title":"Determinism","variant":"solid"},{"accent":"var(--beck-primary)","fields":[],"id":"root-1","kind":"service","methods":[],"order":4,"rank":1,"shape":"card","title":"Diagram Types","variant":"solid"},{"accent":"var(--beck-primary)","fields":[],"id":"root-1-0","kind":"service","methods":[],"order":5,"rank":2,"shape":"pill","title":"Architecture","variant":"solid"},{"accent":"var(--beck-primary)","fields":[],"id":"root-1-1","kind":"service","methods":[],"order":6,"rank":2,"shape":"pill","title":"Sequence","variant":"solid"},{"accent":"var(--beck-primary)","fields":[],"id":"root-1-2","kind":"service","methods":[],"order":7,"rank":2,"shape":"pill","title":"State","variant":"solid"},{"accent":"var(--beck-primary)","fields":[],"id":"root-1-3","kind":"service","methods":[],"order":8,"rank":2,"shape":"pill","title":"Class","variant":"solid"},{"accent":"var(--beck-success)","fields":[],"id":"root-2","kind":"service","methods":[],"order":9,"rank":1,"shape":"card","title":"Packages","variant":"solid"},{"accent":"var(--beck-success)","fields":[],"id":"root-2-0","items":["engine","authoring"],"kind":"service","methods":[],"order":10,"rank":2,"shape":"card","title":"Beck","variant":"solid"},{"accent":"var(--beck-success)","fields":[],"id":"root-2-1","kind":"service","methods":[],"order":11,"rank":2,"shape":"pill","title":"Beck.Skia","variant":"solid"},{"accent":"var(--beck-success)","fields":[],"id":"root-2-1-0","kind":"service","methods":[],"order":12,"rank":3,"shape":"pill","title":"HarfBuzz shaping","variant":"solid"},{"accent":"var(--beck-success)","fields":[],"id":"root-2-1-1","kind":"service","methods":[],"order":13,"rank":3,"shape":"pill","title":"Font metrics","variant":"solid"},{"accent":"var(--beck-success)","fields":[],"id":"root-3","kind":"service","methods":[],"order":14,"rank":1,"shape":"card","title":"Authoring","variant":"solid"},{"accent":"var(--beck-success)","fields":[],"id":"root-3-0","kind":"service","methods":[],"order":15,"rank":2,"shape":"pill","title":"DiagramBuilder","variant":"solid"},{"accent":"var(--beck-success)","fields":[],"id":"root-3-1","kind":"service","methods":[],"order":16,"rank":2,"shape":"pill","title":"YamlWriter","variant":"solid"},{"accent":"var(--beck-danger)","fields":[],"id":"root-4","kind":"service","methods":[],"order":17,"rank":1,"shape":"card","title":"Theming","variant":"solid"},{"accent":"var(--beck-danger)","fields":[],"id":"root-4-0","kind":"service","methods":[],"order":18,"rank":2,"shape":"pill","title":"CSS variables","variant":"solid"},{"accent":"var(--beck-danger)","fields":[],"id":"root-4-1","kind":"service","methods":[],"order":19,"rank":2,"shape":"pill","title":"Dark mode","variant":"solid"}],"sections":[]} \ No newline at end of file diff --git a/tests/Beck.Tests/Goldens/model/mindmap-simple.model.json b/tests/Beck.Tests/Goldens/model/mindmap-simple.model.json new file mode 100644 index 0000000..bdd4a89 --- /dev/null +++ b/tests/Beck.Tests/Goldens/model/mindmap-simple.model.json @@ -0,0 +1 @@ +{"edges":[{"arrow":"none","color":"color-mix(in srgb, var(--beck-info) 55%, var(--beck-edge))","curve":"s","from":"root","id":"root->root-0#0","kind":"data","reply":false,"style":"solid","to":"root-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-info) 55%, var(--beck-edge))","curve":"s","from":"root-0","id":"root-0->root-0-0#1","kind":"data","reply":false,"style":"solid","to":"root-0-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-info) 55%, var(--beck-edge))","curve":"s","from":"root-0","id":"root-0->root-0-1#2","kind":"data","reply":false,"style":"solid","to":"root-0-1"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-primary) 55%, var(--beck-edge))","curve":"s","from":"root","id":"root->root-1#3","kind":"data","reply":false,"style":"solid","to":"root-1"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-primary) 55%, var(--beck-edge))","curve":"s","from":"root-1","id":"root-1->root-1-0#4","kind":"data","reply":false,"style":"solid","to":"root-1-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-primary) 55%, var(--beck-edge))","curve":"s","from":"root-1","id":"root-1->root-1-1#5","kind":"data","reply":false,"style":"solid","to":"root-1-1"}],"flow":{"derived":true,"repeat":-1,"repeatDelay":1.2,"steps":[{"label":"root","type":"phase"},{"from":"root","to":"root-0","type":"packet"},{"from":"root","to":"root-1","type":"packet"},{"label":"root-0","type":"phase"},{"from":"root-0","to":"root-0-0","type":"packet"},{"from":"root-0","to":"root-0-1","type":"packet"},{"label":"root-1","type":"phase"},{"from":"root-1","to":"root-1-0","type":"packet"},{"from":"root-1","to":"root-1-1","type":"packet"},{"seconds":1,"type":"wait"},{"type":"reset"}]},"groups":[],"meta":{"animate":false,"direction":"LR","fit":"shrink","loop":true,"narration":{"enabled":true,"min":1.4,"pad":0.5,"wpm":170},"spacing":{"cornerRadius":16,"node":20,"rank":70},"theme":"auto","title":"Beck Overview","type":"mindmap"},"nodes":[{"accent":"var(--beck-primary)","fields":[],"id":"root","kind":"service","methods":[],"order":0,"rank":0,"shape":"card","title":"Beck","variant":"solid"},{"accent":"var(--beck-info)","fields":[],"id":"root-0","kind":"service","methods":[],"order":1,"rank":1,"shape":"card","title":"Rendering","variant":"solid"},{"accent":"var(--beck-info)","fields":[],"id":"root-0-0","kind":"service","methods":[],"order":2,"rank":2,"shape":"pill","title":"Layout","variant":"solid"},{"accent":"var(--beck-info)","fields":[],"id":"root-0-1","kind":"service","methods":[],"order":3,"rank":2,"shape":"pill","title":"Routing","variant":"solid"},{"accent":"var(--beck-primary)","fields":[],"id":"root-1","kind":"service","methods":[],"order":4,"rank":1,"shape":"card","title":"Packages","variant":"solid"},{"accent":"var(--beck-primary)","fields":[],"id":"root-1-0","kind":"service","methods":[],"order":5,"rank":2,"shape":"pill","title":"Engine","variant":"solid"},{"accent":"var(--beck-primary)","fields":[],"id":"root-1-1","kind":"service","methods":[],"order":6,"rank":2,"shape":"pill","title":"Skia","variant":"solid"}],"sections":[]} \ No newline at end of file diff --git a/tests/Beck.Tests/Goldens/model/mindmap-status.model.json b/tests/Beck.Tests/Goldens/model/mindmap-status.model.json new file mode 100644 index 0000000..92fdcb5 --- /dev/null +++ b/tests/Beck.Tests/Goldens/model/mindmap-status.model.json @@ -0,0 +1 @@ +{"edges":[{"arrow":"none","color":"color-mix(in srgb, var(--beck-info) 55%, var(--beck-edge))","curve":"s","from":"root","id":"root->root-0#0","kind":"data","reply":false,"style":"solid","to":"root-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-info) 55%, var(--beck-edge))","curve":"s","from":"root-0","id":"root-0->root-0-0#1","kind":"data","reply":false,"style":"solid","to":"root-0-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-info) 55%, var(--beck-edge))","curve":"s","from":"root-0","id":"root-0->root-0-1#2","kind":"data","reply":false,"style":"solid","to":"root-0-1"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-primary) 55%, var(--beck-edge))","curve":"s","from":"root","id":"root->root-1#3","kind":"data","reply":false,"style":"solid","to":"root-1"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-primary) 55%, var(--beck-edge))","curve":"s","from":"root-1","id":"root-1->root-1-0#4","kind":"data","reply":false,"style":"solid","to":"root-1-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-primary) 55%, var(--beck-edge))","curve":"s","from":"root-1","id":"root-1->root-1-1#5","kind":"data","reply":false,"style":"solid","to":"root-1-1"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-primary) 55%, var(--beck-edge))","curve":"s","from":"root-1","id":"root-1->root-1-2#6","kind":"data","reply":false,"style":"solid","to":"root-1-2"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-success) 55%, var(--beck-edge))","curve":"s","from":"root","id":"root->root-2#7","kind":"data","reply":false,"style":"solid","to":"root-2"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-success) 55%, var(--beck-edge))","curve":"s","from":"root-2","id":"root-2->root-2-0#8","kind":"data","reply":false,"style":"solid","to":"root-2-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-success) 55%, var(--beck-edge))","curve":"s","from":"root-2","id":"root-2->root-2-1#9","kind":"data","reply":false,"style":"solid","to":"root-2-1"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-neutral) 55%, var(--beck-edge))","curve":"s","from":"root","id":"root->root-3#10","kind":"data","reply":false,"style":"dashed","to":"root-3"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-neutral) 55%, var(--beck-edge))","curve":"s","from":"root-3","id":"root-3->root-3-0#11","kind":"data","reply":false,"style":"dashed","to":"root-3-0"},{"arrow":"none","color":"color-mix(in srgb, var(--beck-neutral) 55%, var(--beck-edge))","curve":"s","from":"root-3","id":"root-3->root-3-1#12","kind":"data","reply":false,"style":"dashed","to":"root-3-1"}],"flow":{"derived":true,"repeat":-1,"repeatDelay":1.2,"steps":[{"label":"root","type":"phase"},{"from":"root","to":"root-0","type":"packet"},{"from":"root","to":"root-1","type":"packet"},{"from":"root","to":"root-2","type":"packet"},{"from":"root","to":"root-3","type":"packet"},{"label":"root-0","type":"phase"},{"from":"root-0","to":"root-0-0","type":"packet"},{"from":"root-0","to":"root-0-1","type":"packet"},{"label":"root-1","type":"phase"},{"from":"root-1","to":"root-1-0","type":"packet"},{"from":"root-1","to":"root-1-1","type":"packet"},{"from":"root-1","to":"root-1-2","type":"packet"},{"label":"root-2","type":"phase"},{"from":"root-2","to":"root-2-0","type":"packet"},{"from":"root-2","to":"root-2-1","type":"packet"},{"label":"root-3","type":"phase"},{"from":"root-3","to":"root-3-0","type":"packet"},{"from":"root-3","to":"root-3-1","type":"packet"},{"seconds":1,"type":"wait"},{"type":"reset"}]},"groups":[],"meta":{"animate":false,"direction":"LR","fit":"shrink","loop":true,"narration":{"enabled":true,"min":1.4,"pad":0.5,"wpm":170},"spacing":{"cornerRadius":16,"node":20,"rank":70},"theme":"auto","title":"Platform Redesign","type":"mindmap"},"nodes":[{"accent":"var(--beck-primary)","fields":[],"icon":"model","id":"root","kind":"service","methods":[],"order":0,"rank":0,"shape":"card","subtitle":"Q3 initiative","title":"Platform Redesign","variant":"solid"},{"accent":"var(--beck-info)","fields":[],"icon":"search","id":"root-0","kind":"service","methods":[],"order":1,"rank":1,"shape":"card","status":"complete","title":"Research","variant":"solid"},{"accent":"var(--beck-info)","fields":[],"id":"root-0-0","kind":"service","methods":[],"order":2,"rank":2,"shape":"pill","title":"User interviews","variant":"solid"},{"accent":"var(--beck-info)","fields":[],"id":"root-0-1","kind":"service","methods":[],"order":3,"rank":2,"shape":"pill","title":"Analytics audit","variant":"solid"},{"accent":"var(--beck-primary)","fields":[],"icon":"browser","id":"root-1","kind":"service","methods":[],"order":4,"rank":1,"shape":"card","title":"Design","variant":"solid"},{"accent":"var(--beck-primary)","fields":[],"id":"root-1-0","kind":"service","methods":[],"order":5,"rank":2,"shape":"pill","title":"Design tokens","variant":"solid"},{"accent":"var(--beck-primary)","fields":[],"id":"root-1-1","kind":"service","methods":[],"order":6,"rank":2,"shape":"pill","title":"Component library","variant":"solid"},{"accent":"var(--beck-primary)","fields":[],"id":"root-1-2","kind":"service","methods":[],"order":7,"rank":2,"shape":"pill","title":"Prototype","variant":"solid"},{"accent":"var(--beck-success)","fields":[],"icon":"code","id":"root-2","kind":"service","methods":[],"order":8,"rank":1,"shape":"card","status":"in progress","title":"Engineering","variant":"solid"},{"accent":"var(--beck-success)","fields":[],"id":"root-2-0","kind":"service","methods":[],"order":9,"rank":2,"shape":"pill","title":"API migration","variant":"solid"},{"accent":"var(--beck-success)","fields":[],"id":"root-2-1","kind":"service","methods":[],"order":10,"rank":2,"shape":"pill","title":"Frontend rebuild","variant":"solid"},{"accent":"var(--beck-neutral)","fields":[],"icon":"cloud","id":"root-3","kind":"service","methods":[],"order":11,"rank":1,"shape":"card","title":"Launch","variant":"ghost"},{"accent":"var(--beck-neutral)","fields":[],"id":"root-3-0","kind":"service","methods":[],"order":12,"rank":2,"shape":"pill","title":"Beta program","variant":"ghost"},{"accent":"var(--beck-neutral)","fields":[],"id":"root-3-1","kind":"service","methods":[],"order":13,"rank":2,"shape":"pill","title":"Marketing site","variant":"ghost"}],"sections":[]} \ No newline at end of file diff --git a/tests/Beck.Tests/Goldens/svg/flowchart-branchy.svg b/tests/Beck.Tests/Goldens/svg/flowchart-branchy.svg new file mode 100644 index 0000000..05faf1c --- /dev/null +++ b/tests/Beck.Tests/Goldens/svg/flowchart-branchy.svg @@ -0,0 +1 @@ +Request Handlingyesnogive upRead requestValidate inputValid?Process requestFix inputGive upNotify caller \ No newline at end of file diff --git a/tests/Beck.Tests/Goldens/svg/flowchart-simple.svg b/tests/Beck.Tests/Goldens/svg/flowchart-simple.svg new file mode 100644 index 0000000..949d6a5 --- /dev/null +++ b/tests/Beck.Tests/Goldens/svg/flowchart-simple.svg @@ -0,0 +1 @@ +Deploy PipelineBuild artifact \ No newline at end of file diff --git a/tests/Beck.Tests/Goldens/svg/mindmap-kitchen.svg b/tests/Beck.Tests/Goldens/svg/mindmap-kitchen.svg new file mode 100644 index 0000000..2362312 --- /dev/null +++ b/tests/Beck.Tests/Goldens/svg/mindmap-kitchen.svg @@ -0,0 +1 @@ +Beck ArchitectureBeckserver-side diagramsRenderingPipeline• Model• Text• Layout• Route• Svg• AnimateDeterminismSame YAML and the same options alwaysproduce byte-identical SVG, ids from a contenthash.Diagram TypesArchitectureSequenceStateClassPackagesBeck• engine• authoringBeck.SkiaHarfBuzz shapingFont metricsAuthoringDiagramBuilderYamlWriterThemingCSS variablesDark mode \ No newline at end of file diff --git a/tests/Beck.Tests/Goldens/svg/mindmap-simple.svg b/tests/Beck.Tests/Goldens/svg/mindmap-simple.svg new file mode 100644 index 0000000..76539d0 --- /dev/null +++ b/tests/Beck.Tests/Goldens/svg/mindmap-simple.svg @@ -0,0 +1 @@ +Beck OverviewBeckRenderingLayoutRoutingPackagesEngineSkia \ No newline at end of file diff --git a/tests/Beck.Tests/Goldens/svg/mindmap-status.svg b/tests/Beck.Tests/Goldens/svg/mindmap-status.svg new file mode 100644 index 0000000..ccaa01c --- /dev/null +++ b/tests/Beck.Tests/Goldens/svg/mindmap-status.svg @@ -0,0 +1 @@ +Platform RedesignPlatform RedesignQ3 initiativeResearchcompleteUser interviewsAnalytics auditDesignDesign tokensComponent libraryPrototypeEngineeringin progressAPI migrationFrontend rebuildLaunchplannedBeta programMarketing site \ No newline at end of file diff --git a/tests/Beck.Tests/MindMapBuilderTests.cs b/tests/Beck.Tests/MindMapBuilderTests.cs new file mode 100644 index 0000000..672ad61 --- /dev/null +++ b/tests/Beck.Tests/MindMapBuilderTests.cs @@ -0,0 +1,193 @@ +using Beck.Authoring; +using Beck.Model; +using Xunit; +using AccentToken = Beck.Authoring.AccentToken; + +namespace Beck.Tests; + +/// +/// The authoring side of the type: mindmap schema contract +/// (): fluent-built YAML must parse via +/// into the same model shapes the hand-written +/// YAML in exercises — nested topics emit as flow-style +/// YAML (still plain YAML, just single-line), so nesting depth must round-trip +/// exactly like the block-style schema examples do. +/// +public sealed class MindMapBuilderTests +{ + [Fact] + public void RootAndThreeLevelsOfNesting_RoundTrip() + { + var yaml = new MindMapDiagramBuilder("Beck") + .Root("Beck") + .Topic("Rendering", t => t + .Accent(AccentToken.Info) + .Topic("Pipeline", p => p + .Items("Model", "Text", "Layout") + .Topic("Model", m => m.Body("YAML → DiagramModel")))) + .Topic("Packages", t => t + .Topic("Beck") + .Topic("Beck.Skia")) + .ToYaml(); + + var model = Validate.LoadDiagram(yaml); + + Assert.Equal(new[] { "root", "root-0", "root-0-0", "root-0-0-0", "root-1", "root-1-0", "root-1-1" }, + model.Nodes.Select(n => n.Id).ToArray()); + + Assert.Equal("Beck", model.Nodes.Single(n => n.Id == "root").Title); + Assert.Equal("Rendering", model.Nodes.Single(n => n.Id == "root-0").Title); + Assert.Equal("Pipeline", model.Nodes.Single(n => n.Id == "root-0-0").Title); + Assert.Equal("Model", model.Nodes.Single(n => n.Id == "root-0-0-0").Title); + + // Ranks = depth. + Assert.Equal(0d, model.Nodes.Single(n => n.Id == "root").Rank); + Assert.Equal(1d, model.Nodes.Single(n => n.Id == "root-0").Rank); + Assert.Equal(2d, model.Nodes.Single(n => n.Id == "root-0-0").Rank); + Assert.Equal(3d, model.Nodes.Single(n => n.Id == "root-0-0-0").Rank); + + // Items/body pass through. + Assert.Equal(new[] { "Model", "Text", "Layout" }, model.Nodes.Single(n => n.Id == "root-0-0").Items); + Assert.Equal("YAML → DiagramModel", model.Nodes.Single(n => n.Id == "root-0-0-0").Body); + + // Accent set on "Rendering" flows down to its descendants. + Assert.Equal("var(--beck-info)", model.Nodes.Single(n => n.Id == "root-0").Accent); + Assert.Equal("var(--beck-info)", model.Nodes.Single(n => n.Id == "root-0-0").Accent); + Assert.Equal("var(--beck-info)", model.Nodes.Single(n => n.Id == "root-0-0-0").Accent); + + // Edges: parent → child, S-curve, no arrow. + Assert.Equal(6, model.Edges.Count); + Assert.All(model.Edges, e => + { + Assert.Equal(Beck.Model.EdgeCurve.S, e.Curve); + Assert.Equal(Beck.Model.ArrowEnds.None, e.Arrow); + }); + } + + [Fact] + public void ExplicitAccentOverride_ResetsInheritanceForItsSubtree() + { + var yaml = new MindMapDiagramBuilder() + .Root("C") + .Topic("A", a => a.Topic("A1")) + .Topic("B", b => b + .Accent(AccentToken.Danger) + .Topic("B1", b1 => b1.Accent("#00ff00").Topic("B1a")) + .Topic("B2")) + .ToYaml(); + + var model = Validate.LoadDiagram(yaml); + NodeModel N(string id) => model.Nodes.Single(n => n.Id == id); + + Assert.Equal("var(--beck-primary)", N("root").Accent); + Assert.Equal("var(--beck-danger)", N("root-1").Accent); // explicit override on B + Assert.Equal("var(--beck-danger)", N("root-1-1").Accent); // B2 inherits B's override + Assert.Equal("#00ff00", N("root-1-0").Accent); // B1 explicit raw color + Assert.Equal("#00ff00", N("root-1-0-0").Accent); // B1a inherits B1's raw color + } + + [Fact] + public void ItemsBodyIdSubtitle_PassThrough() + { + var yaml = new MindMapDiagramBuilder() + .Root("R") + .Topic("Pipeline", t => t.Id("pipe").Subtitle("core stages").Items("Model", "Text")) + .Topic("Notes", t => t.Body("A wrapped paragraph.")) + .ToYaml(); + + var model = Validate.LoadDiagram(yaml); + var pipeline = model.Nodes.Single(n => n.Id == "pipe"); + Assert.Equal("Pipeline", pipeline.Title); + Assert.Equal("core stages", pipeline.Subtitle); + Assert.Equal(new[] { "Model", "Text" }, pipeline.Items); + + Assert.Equal("A wrapped paragraph.", model.Nodes.Single(n => n.Id == "root-1").Body); + } + + [Fact] + public void MetaTitleAndStyle_Emit() + { + var yaml = new MindMapDiagramBuilder("My Mindmap") + .Style("classic") + .Root("Center") + .Topic("A") + .ToYaml(); + + Assert.Contains("type: mindmap", yaml); + Assert.Contains("title: My Mindmap", yaml); + Assert.Contains("style: classic", yaml); + + var model = Validate.LoadDiagram(yaml); + Assert.Equal("My Mindmap", model.Meta.Title); + } + + [Fact] + public void DeepNesting_FourLevels_IndentsAndParsesCorrectly() + { + var yaml = new MindMapDiagramBuilder() + .Root("L0") + .Topic("L1", l1 => l1 + .Topic("L2", l2 => l2 + .Topic("L3", l3 => l3 + .Topic("L4")))) + .ToYaml(); + + var model = Validate.LoadDiagram(yaml); + Assert.Equal(new[] { "root", "root-0", "root-0-0", "root-0-0-0", "root-0-0-0-0" }, + model.Nodes.Select(n => n.Id).ToArray()); + Assert.Equal("L4", model.Nodes.Single(n => n.Id == "root-0-0-0-0").Title); + Assert.Equal(4d, model.Nodes.Single(n => n.Id == "root-0-0-0-0").Rank); + + // Depth roles: root and rank 1 are always cards; a rank 2+ heading is a pill even with children. + Assert.Equal(NodeShape.Pill, model.Nodes.Single(n => n.Id == "root-0-0-0-0").Shape); // L4 leaf + Assert.Equal(NodeShape.Pill, model.Nodes.Single(n => n.Id == "root-0-0-0").Shape); // L3 hub (rank 3) + Assert.Equal(NodeShape.Card, model.Nodes.Single(n => n.Id == "root-0").Shape); // L1 (rank 1) + } + + [Fact] + public void SpecialCharactersInTitle_QuoteAndEscapeThroughNesting() + { + var yaml = new MindMapDiagramBuilder() + .Root("Beck: \"Mermaid, but sexy\"") + .Topic("Say \"hi\": greetings", t => t + .Topic("Nested: colon, comma \"quote\"")) + .ToYaml(); + + var model = Validate.LoadDiagram(yaml); + Assert.Equal("Beck: \"Mermaid, but sexy\"", model.Nodes.Single(n => n.Id == "root").Title); + Assert.Equal("Say \"hi\": greetings", model.Nodes.Single(n => n.Id == "root-0").Title); + Assert.Equal("Nested: colon, comma \"quote\"", model.Nodes.Single(n => n.Id == "root-0-0").Title); + } + + [Fact] + public void ScriptedFlow_Emits() + { + var yaml = new MindMapDiagramBuilder() + .Root("R") + .Topic("A") + .Flow(f => f.Packet("root", "root-0", label: "go")) + .ToYaml(); + + Assert.Contains("flow:", yaml); + Assert.Contains("packet:", yaml); + Validate.LoadDiagram(yaml); + } + + [Fact] + public void MissingRoot_Throws() + { + Assert.Throws(() => new MindMapDiagramBuilder().Topic("A").ToYaml()); + } + + [Fact] + public void ToFence_WrapsInBeckCodeBlock() + { + var fence = new MindMapDiagramBuilder() + .Root("R") + .Topic("A") + .ToFence(); + + Assert.StartsWith("```beck\n", fence); + Assert.EndsWith("```\n", fence); + } +} diff --git a/tests/Beck.Tests/MindMapTests.cs b/tests/Beck.Tests/MindMapTests.cs new file mode 100644 index 0000000..5a387ee --- /dev/null +++ b/tests/Beck.Tests/MindMapTests.cs @@ -0,0 +1,323 @@ +using Beck.Layout; +using Beck.Model; +using Beck.Text; +using Xunit; +using Xunit.Abstractions; + +namespace Beck.Tests; + +/// +/// Gates type: mindmap: the tree flattening (ids, ranks, orders, +/// accent cycling + inheritance, shape mapping, edge defaults) and the +/// two-sided butterfly (root centred between disjoint left/right halves, no overlaps, no negative +/// coordinates), plus a render-determinism smoke check. +/// +public sealed class MindMapTests +{ + private readonly ITestOutputHelper _out; + + public MindMapTests(ITestOutputHelper output) => _out = output; + + private static (DiagramModel Model, LayoutResult Layout) Run(string yaml) + { + var model = Validate.LoadDiagram(yaml); + var sizes = model.Nodes.ToDictionary(n => n.Id, n => CardSizer.Measure(n, InterMetricsMeasurer.Instance, mindMap: true)); + return (model, MindMapLayout.Compute(model, sizes)); + } + + // ---------------------------------------------------------------- model + + [Fact] + public void TreeFlattening_AssignsIdsRanksAndOrders() + { + const string yaml = """ + type: mindmap + root: Center + topics: + - title: A + children: + - title: A1 + - title: A2 + - title: B + """; + var model = Validate.LoadDiagram(yaml); + + // Path-based ids, deterministic and collision-free. + Assert.Equal(new[] { "root", "root-0", "root-0-0", "root-0-1", "root-1" }, + model.Nodes.Select(n => n.Id).ToArray()); + + // Rank = depth (root 0), Order = stable traversal index. + Assert.Equal(0d, model.Nodes.Single(n => n.Id == "root").Rank); + Assert.Equal(1d, model.Nodes.Single(n => n.Id == "root-0").Rank); + Assert.Equal(2d, model.Nodes.Single(n => n.Id == "root-0-0").Rank); + Assert.Equal(Enumerable.Range(0, 5).Select(i => (double)i).ToArray(), + model.Nodes.Select(n => n.Order!.Value).ToArray()); + } + + [Fact] + public void RootStringShorthand_IsTheTitle() + { + var model = Validate.LoadDiagram("type: mindmap\nroot: Beck\ntopics: []\n"); + var root = model.Nodes.Single(); + Assert.Equal("Beck", root.Title); + Assert.Equal("root", root.Id); + Assert.Equal(NodeShape.Card, root.Shape); // root is always a card + } + + [Fact] + public void AuthoredId_IsHonoured() + { + var model = Validate.LoadDiagram("type: mindmap\nroot: { id: hub, title: Hub }\ntopics: [{ id: r, title: R }]\n"); + Assert.Contains(model.Nodes, n => n.Id == "hub"); + Assert.Contains(model.Nodes, n => n.Id == "r"); + } + + [Fact] + public void AccentCycling_InheritanceAndExplicitOverride() + { + const string yaml = """ + type: mindmap + root: Center + topics: + - title: A + children: + - title: A1 + - title: B + accent: danger + children: + - title: B1 + """; + var model = Validate.LoadDiagram(yaml); + NodeModel N(string id) => model.Nodes.Single(n => n.Id == id); + + Assert.Equal("var(--beck-primary)", N("root").Accent); // root → primary + Assert.Equal("var(--beck-info)", N("root-0").Accent); // branch 0 → cycle[0] = info + Assert.Equal("var(--beck-info)", N("root-0-0").Accent); // inherits A + Assert.Equal("var(--beck-danger)", N("root-1").Accent); // explicit override + Assert.Equal("var(--beck-danger)", N("root-1-0").Accent); // override flows to child + } + + [Fact] + public void AccentCycle_WrapsPastFiveBranches() + { + var topics = string.Join("\n", Enumerable.Range(0, 7).Select(i => $" - title: T{i}")); + var model = Validate.LoadDiagram($"type: mindmap\nroot: C\ntopics:\n{topics}\n"); + // cycle = [info, primary, success, warn, danger] (neutral reserved for ghosts); branch 5 wraps to info. + Assert.Equal("var(--beck-info)", model.Nodes.Single(n => n.Id == "root-0").Accent); // branch 0 → info + Assert.Equal("var(--beck-primary)", model.Nodes.Single(n => n.Id == "root-1").Accent); // branch 1 → primary + Assert.Equal("var(--beck-danger)", model.Nodes.Single(n => n.Id == "root-4").Accent); // branch 4 → danger + Assert.Equal("var(--beck-info)", model.Nodes.Single(n => n.Id == "root-5").Accent); // wraps → info + Assert.Equal("var(--beck-primary)", model.Nodes.Single(n => n.Id == "root-6").Accent); // → primary + } + + [Fact] + public void ItemsAndBody_PassThrough() + { + const string yaml = """ + type: mindmap + root: C + topics: + - title: Pipeline + items: [Model, Text, Layout] + - title: Determinism + body: Same YAML, same SVG. + """; + var model = Validate.LoadDiagram(yaml); + Assert.Equal(new[] { "Model", "Text", "Layout" }, model.Nodes.Single(n => n.Id == "root-0").Items); + Assert.Equal("Same YAML, same SVG.", model.Nodes.Single(n => n.Id == "root-1").Body); + } + + [Fact] + public void ShapeMapping_LeafPillHubAndContentCard() + { + const string yaml = """ + type: mindmap + root: R + topics: + - title: Leaf + - title: Hub + children: + - title: Deep + - title: Content + items: [x, y] + """; + var model = Validate.LoadDiagram(yaml); + Assert.Equal(NodeShape.Card, model.Nodes.Single(n => n.Id == "root").Shape); // root + Assert.Equal(NodeShape.Card, model.Nodes.Single(n => n.Id == "root-0").Shape); // rank-1 is always a card + Assert.Equal(NodeShape.Card, model.Nodes.Single(n => n.Id == "root-1").Shape); // rank-1 hub + Assert.Equal(NodeShape.Pill, model.Nodes.Single(n => n.Id == "root-1-0").Shape); // rank-2 heading-only leaf → pill + Assert.Equal(NodeShape.Card, model.Nodes.Single(n => n.Id == "root-2").Shape); // rank-1 content + } + + [Fact] + public void Edges_AreParentToChild_SCurve_NoArrow_ColouredByChild() + { + var model = Validate.LoadDiagram("type: mindmap\nroot: C\ntopics: [{ title: A, accent: warn }]\n"); + var e = model.Edges.Single(); + Assert.Equal("root", e.From); + Assert.Equal("root-0", e.To); + Assert.Equal(EdgeCurve.S, e.Curve); + Assert.Equal(ArrowEnds.None, e.Arrow); + Assert.Equal(EdgeStyle.Solid, e.Style); + // A muted branch thread: the child's accent blended 55% into the edge token. + Assert.Equal("color-mix(in srgb, var(--beck-warn) 55%, var(--beck-edge))", e.Color); + } + + [Fact] + public void MissingTitle_DefaultsToId() + { + var model = Validate.LoadDiagram("type: mindmap\nroot: { id: hub }\ntopics: []\n"); + Assert.Equal("hub", model.Nodes.Single().Title); + } + + [Fact] + public void DuplicateId_Throws() + { + var ex = Assert.Throws(() => + Validate.LoadDiagram("type: mindmap\nroot: { id: dup }\ntopics: [{ id: dup, title: X }]\n")); + Assert.Contains("Duplicate", ex.Message); + } + + [Fact] + public void MissingRoot_Throws() + { + var ex = Assert.Throws(() => Validate.LoadDiagram("type: mindmap\ntopics: []\n")); + Assert.Contains("root", ex.Message); + } + + // ---------------------------------------------------------------- butterfly layout + + private const string FourBranch = """ + type: mindmap + root: Root + topics: + - title: North + children: [{ title: N1 }, { title: N2 }] + - title: East + children: [{ title: E1 }, { title: E2 }] + - title: South + children: [{ title: S1 }, { title: S2 }] + - title: West + children: [{ title: W1 }, { title: W2 }] + """; + + [Fact] + public void Butterfly_RootCentredBetweenDisjointHalves_NoOverlaps_NoNegatives() + { + var (_, layout) = Run(FourBranch); + var root = layout.Nodes["root"]; + var rootCx = root.X + root.W / 2; + + var others = layout.Nodes.Where(kv => kv.Key != "root").ToList(); + var leftNodes = others.Where(kv => kv.Value.X + kv.Value.W / 2 < rootCx).Select(kv => kv.Value).ToList(); + var rightNodes = others.Where(kv => kv.Value.X + kv.Value.W / 2 >= rootCx).Select(kv => kv.Value).ToList(); + + Assert.NotEmpty(leftNodes); + Assert.NotEmpty(rightNodes); + + // Every left-half rect is entirely left of the root's left edge; every right-half rect is + // entirely right of the root's right edge — the halves are disjoint and the root sits between. + foreach (var r in leftNodes) + { + Assert.True(r.X + r.W <= root.X + 0.01, $"left node right edge {r.X + r.W} must be ≤ root left {root.X}"); + } + + foreach (var r in rightNodes) + { + Assert.True(r.X >= root.X + root.W - 0.01, $"right node left edge {r.X} must be ≥ root right {root.X + root.W}"); + } + + // No pairwise overlap. + var all = layout.Nodes.Values.ToList(); + for (var i = 0; i < all.Count; i++) + { + for (var j = i + 1; j < all.Count; j++) + { + Assert.False(Overlap(all[i], all[j]), $"rects {i} and {j} overlap"); + } + } + + // No negative coordinates. + foreach (var r in all) + { + Assert.True(r.X >= 0 && r.Y >= 0, $"rect ({r.X},{r.Y}) is off-canvas"); + } + + // Total canvas fits every rect plus padding. + Assert.True(layout.Width >= all.Max(r => r.X + r.W)); + Assert.True(layout.Height >= all.Max(r => r.Y + r.H)); + } + + [Fact] + public void Butterfly_SingleBranch_GoesRight() + { + var (_, layout) = Run("type: mindmap\nroot: R\ntopics: [{ title: Only }]\n"); + var root = layout.Nodes["root"]; + var branch = layout.Nodes["root-0"]; + Assert.True(branch.X >= root.X + root.W - 0.01, "a single branch lands on the right side"); + } + + [Fact] + public void Butterfly_ReportsExampleLayoutNumbers() + { + var (model, layout) = Run(FourBranch); + var root = layout.Nodes["root"]; + var rootCx = root.X + root.W / 2; + _out.WriteLine($"canvas = {layout.Width} x {layout.Height}"); + _out.WriteLine($"root rect = ({root.X:0.##},{root.Y:0.##}) {root.W:0.##}x{root.H:0.##} centreX = {rootCx:0.##}"); + foreach (var n in model.Nodes) + { + var r = layout.Nodes[n.Id]; + var side = n.Id == "root" ? "root" : (r.X + r.W / 2 < rootCx ? "L" : "R"); + _out.WriteLine($" {n.Id,-10} {side,-4} ({r.X,7:0.##},{r.Y,7:0.##}) {r.W:0.##}x{r.H:0.##}"); + } + } + + // ---------------------------------------------------------------- render smoke + + [Fact] + public void Render_IsDeterministic_AndEmitsSCurves() + { + const string yaml = """ + type: mindmap + meta: { title: Beck } + root: + title: Beck + topics: + - title: Rendering + accent: info + children: + - title: Pipeline + items: [Model, Text, Layout] + - title: Determinism + body: Same YAML, same SVG. + - title: Packages + children: + - title: Beck + - title: Beck.Skia + """; + var first = BeckSvg.Render(yaml); + var second = BeckSvg.Render(yaml); + Assert.Equal(first, second); + Assert.StartsWith(" + /// Rewrites the mindmap model goldens in the SOURCE tree from the current engine (the TS oracle these + /// goldens once came from is gone; the C# engine is now the reference for the C#-native diagram types). + /// Guarded by BECK_REGEN=1. Only for an intentional model change — diff before committing. + /// + [Fact] + public void Regenerate() + { + if (Environment.GetEnvironmentVariable("BECK_REGEN") != "1") + { + return; + } + + var srcModel = Path.Combine(SourceDir(), "Goldens", "model"); + foreach (var name in new[] { "mindmap-simple", "mindmap-kitchen", "mindmap-status" }) + { + var model = Validate.LoadDiagram(File.ReadAllText(Path.Combine(_corpusDir, name + ".yaml"))); + File.WriteAllText(Path.Combine(srcModel, name + ".model.json"), ModelJson.Canonical(model)); + } + } + + private static string SourceDir([System.Runtime.CompilerServices.CallerFilePath] string self = "") => + Path.GetDirectoryName(self)!; + /// Structural first-difference path for a readable failure message. private static string? FirstDifference(string expectedJson, string actualJson) { diff --git a/tests/Beck.Tests/RegenGoldens.cs b/tests/Beck.Tests/RegenGoldens.cs index 3d62a1a..c7b5ad5 100644 --- a/tests/Beck.Tests/RegenGoldens.cs +++ b/tests/Beck.Tests/RegenGoldens.cs @@ -52,6 +52,10 @@ private static int RegenSvg() ("arch-kitchen", "arch-kitchen", "cla551c0", BeckStyle.Classic), ("seq-kitchen", "seq-kitchen", "cla551c0", BeckStyle.Classic), ("class", "class", "cla551c0", BeckStyle.Classic), + ("flowchart-simple", "flowchart-simple", "cla551c0", BeckStyle.Classic), + ("flowchart-branchy", "flowchart-branchy", "cla551c0", BeckStyle.Classic), + ("mindmap-simple", "mindmap-simple", "cla551c0", BeckStyle.Classic), + ("mindmap-kitchen", "mindmap-kitchen", "cla551c0", BeckStyle.Classic), ("minimal", "arch-kitchen", "min1ma1c", MinimalStyle.Instance), ("terminal", "arch-kitchen", "term1na1", TerminalStyle.Instance), ("blueprint", "arch-kitchen", "b1uepr1n", BlueprintStyle.Instance), diff --git a/tests/Beck.Tests/RenderSmokeTests.cs b/tests/Beck.Tests/RenderSmokeTests.cs index 57c05ab..52e4930 100644 --- a/tests/Beck.Tests/RenderSmokeTests.cs +++ b/tests/Beck.Tests/RenderSmokeTests.cs @@ -20,7 +20,7 @@ public void RenderCorpusToFiles() var outDir = Path.Combine(RepoRoot, "tools", "oracle", "rendered"); Directory.CreateDirectory(outDir); - foreach (var file in new[] { "arch-simple", "arch-grouped", "arch-flow", "arch-kitchen", "state", "class", "sample-architecture", "sequence", "seq-kitchen", "sample-sequence" }) + foreach (var file in new[] { "arch-simple", "arch-grouped", "arch-flow", "arch-kitchen", "state", "class", "sample-architecture", "sequence", "seq-kitchen", "sample-sequence", "flowchart-simple", "flowchart-branchy", "mindmap-simple", "mindmap-kitchen", "mindmap-status" }) { var yaml = File.ReadAllText(Path.Combine(corpus, file + ".yaml")); var svg = BeckSvg.Render(yaml, options); From 4e1fc3e71ad1119ff554422fa5e1459fd4063861 Mon Sep 17 00:00:00 2001 From: Phil Scott Date: Sat, 11 Jul 2026 16:22:31 -0400 Subject: [PATCH 3/4] feat(playground): flowchart examples + add-playground-example skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Flowchart group to the docs playground's Examples picker: - Two example diagrams — "Deploy gate" (TB, decisions + rollback loop) and "Ticket triage" (LR, routed by urgency) — under wwwroot/examples. - Register the group in PlaygroundIsland's ExampleGroups (glyph, pill tag). - Teal `data-type="flowchart"` pill colour (light + dark) in BrandStyling. Also add the repo's first Claude skill, add-playground-example, documenting the three-file process (author YAML, register in the picker, add the pill colour) so future playground additions are mechanical. --- .../skills/add-playground-example/SKILL.md | 125 ++++++++++++++++++ docs/Beck.Docs.Client/PlaygroundIsland.razor | 4 + docs/Beck.Docs/BrandStyling.cs | 2 + .../examples/flowchart-deploy.beck.yaml | 21 +++ .../examples/flowchart-triage.beck.yaml | 22 +++ 5 files changed, 174 insertions(+) create mode 100644 .claude/skills/add-playground-example/SKILL.md create mode 100644 docs/Beck.Docs/wwwroot/examples/flowchart-deploy.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/flowchart-triage.beck.yaml diff --git a/.claude/skills/add-playground-example/SKILL.md b/.claude/skills/add-playground-example/SKILL.md new file mode 100644 index 0000000..88abf2a --- /dev/null +++ b/.claude/skills/add-playground-example/SKILL.md @@ -0,0 +1,125 @@ +--- +name: add-playground-example +description: Add a new example diagram (or a whole new diagram-type group) to the Beck docs playground picker at docs/Beck.Docs. Use when asked to "add X to the playground", "put a flowchart/sequence/etc. example in the playground", "add a new example to the playground dropdown", or to wire up a new diagram type in the playground's Examples menu. Covers authoring the example YAML, registering it in the picker, and adding the type pill colour. +--- + +# Add a playground example + +The `/playground` page (docs site) has an **Examples** dropdown grouped by diagram type. Each entry +loads a YAML file over HTTP and renders it live. Adding one means three things at most: + +1. **Author** the example YAML (always). +2. **Register** it in the picker's `ExampleGroups` (always). +3. **Add a type pill colour** in `BrandStyling.cs` — **only when introducing a new diagram type**. + +Then verify it renders. Details below. + +## The three files + +| Concern | File | +| --- | --- | +| Example YAML | `docs/Beck.Docs/wwwroot/examples/.beck.yaml` | +| Picker registration | `docs/Beck.Docs.Client/PlaygroundIsland.razor` → `ExampleGroups` array | +| Type pill colour (new type only) | `docs/Beck.Docs/BrandStyling.cs` → `.pg-pill[data-type="…"]` rules | + +> **Location matters.** Playground examples live at the **root** of `wwwroot/examples/`. The +> `wwwroot/examples/guides/`, `/reference/`, `/styles/` subfolders belong to the docs *pages* (loaded +> by `` ```beck:symbol `` fences) — putting a playground example there means the picker's `Src` path +> (`/examples/.beck.yaml`) won't resolve. + +## 1. Author the example YAML + +Write `docs/Beck.Docs/wwwroot/examples/.beck.yaml`. Match the house style of the existing +examples (read a couple first, e.g. `webhook-retry.beck.yaml`, `order-lifecycle.beck.yaml`): + +- Lead with `type:` (`architecture` | `sequence` | `state` | `class` | `flowchart` | `mindmap`). +- `meta:` with a `title`, an optional one-line `subtitle`, and a `direction` (`TB`/`LR`). +- A **recognisable real-world scenario**, kept small — roughly 4–7 nodes reads best in the pane. +- **Accent tokens, never hex**: `primary` `success` `warn` `danger` `info` `neutral`. Colours are + CSS tokens so the diagram adopts the site palette and the playground colour-scheme picker. +- Add a `note:` to a few edges / links / transitions — the engine derives a flow from these and + narrates them in the caption bar (no `flow:` block needed for a nice default animation). +- Keep it deterministic: no counters, timestamps, or RNG anywhere. + +Schema references: `docs/Beck.Docs/Content/docs/reference/yaml.md`, and the per-type model builders +in `src/Beck/Model/*Builder.cs` (they list every field and the exact tokens). + +## 2. Register it in the picker + +In `docs/Beck.Docs.Client/PlaygroundIsland.razor`, find the `ExampleGroups` static array. + +**Adding to an existing type** — append an `Ex` to that group's `Items`: + +```csharp +new Ex("Deploy gate", "Build, gate on green, deploy, then smoke-test with a rollback loop", "/examples/flowchart-deploy.beck.yaml"), +``` + +`Ex(Label, Desc, Src)` — `Label` is the bold title, `Desc` the one-line grey blurb, `Src` the site +path `/examples/.beck.yaml` (leading slash; fetched relative to the app base). Two examples per +type is the established cadence for the smaller types. + +**Adding a new diagram type** — add a new `ExGroup`: + +```csharp +new("flowchart", "Flowchart", "◇", "flow", [ + new Ex("Deploy gate", "Build, gate on green, deploy, then smoke-test with a rollback loop", "/examples/flowchart-deploy.beck.yaml"), + new Ex("Ticket triage", "An inbound support ticket routed by urgency and prior art", "/examples/flowchart-triage.beck.yaml") +]), +``` + +`ExGroup(Type, Label, Glyph, Short, Items)`: +- **`Type`** must equal the diagram's `type:` — it drives the pill's `data-type` (step 3). +- **`Glyph` + `Short`** are the pill's icon and 3–5 char tag. Pick a distinct unicode glyph. + Existing: `⬡ arch` · `▷ seq` · `◈ state` · `▤ class` · `◇ flow` · `❋ map`. + +## 3. Add the type pill colour (new type only) + +Skip this if you reused an existing type. For a new `Type`, add a light **and** a `.dark` rule in +`docs/Beck.Docs/BrandStyling.cs`, next to the other `.pg-pill[data-type="…"]` rules. Pick a hue +distinct from the ones already in use (emerald / sky / amber / violet / teal / rose): + +```css +.pg-pill[data-type="flowchart"] { color: #0f766e; background: color-mix(in srgb, #14b8a6 15%, transparent); border-color: color-mix(in srgb, #14b8a6 30%, transparent); } +.dark .pg-pill[data-type="flowchart"] { color: #5eead4; } +``` + +Keep the `color-mix(...)` + literal-fallback shape of the neighbours (don't resolve to a flat hex). + +## 4. Verify it renders + +The picker loads examples at **runtime over HTTP**, so a docs build will *not* catch a malformed +YAML. Verify one of these ways: + +- **Quick engine check (preferred).** Drop a temporary `[Theory]` into `tests/Beck.Tests` that reads + each new file and renders it, then delete it after: + + ```csharp + [Theory] + [InlineData("flowchart-deploy")] + public void PlaygroundExampleRenders(string name) + { + var font = TestFonts.Spec(); + using var measurer = new SkiaTextMeasurer(font); + var path = Path.Combine(/* repo root */, "docs", "Beck.Docs", "wwwroot", "examples", name + ".beck.yaml"); + var info = BeckSvg.RenderWithInfo(File.ReadAllText(path), new SvgRenderOptions { Measurer = measurer, Font = font }); + Assert.StartsWith(" Date: Sat, 11 Jul 2026 20:36:05 -0400 Subject: [PATCH 4/4] feat(chart): `type: chart` data charts (bar, line, pie, donut, scatter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a chart root `type:` that renders a small, static data chart on its own painter, bypassing the node/edge layout+route+animate pipeline. Charts share the meta/theming shell with every other diagram but carry no nodes, edges, or flow. - Model: ChartModel/ChartSeries/ChartPoint schema + ChartBuilder parser, wired through DiagramType.Chart, Tokens, Defaults, and Validate. Which series member carries data depends on kind (bar/pie/donut → one magnitude, line → a value list, scatter → x/y points). - Svg: ChartPainter draws bar/line/pie/donut/scatter plus an optional legend (top/right/bottom/none, optional value annotations) and pie/donut centre labels; non-finite data collapses to 0 so no bad coordinate reaches the SVG. ChartColors derives every series colour from `--beck-primary` via the chosen palette (analogous/monochromatic/complementary/sequential) as color-mix/relative-colour expressions, so a chart re-tints with the host palette and flips light↔dark like the rest of Beck. A series may pin its own `color:`. - Authoring: ChartDiagramBuilder fluent API + ChartKind/ChartPalette/ LegendPlacement enums and their `Tokens.Of` mappings. - Docs: chart guide, yaml reference, for-ai.llms.md; five wwwroot examples and four guide snippets; playground picker entry + pill colour. - Tests: ChartTests and ChartQuality clean-line coverage. Full suite green (533 tests). Also gitignore `*.snupkg` and `/dist/` local pack output, and add oracle renders for the flowchart/mindmap types. --- .gitignore | 5 + docs/Beck.Docs.Client/PlaygroundIsland.razor | 7 + docs/Beck.Docs/BrandStyling.cs | 2 + docs/Beck.Docs/Content/docs/guides/chart.md | 123 ++++++ .../Content/docs/guides/custom-styles.md | 2 +- docs/Beck.Docs/Content/docs/guides/flow.md | 2 +- .../Beck.Docs/Content/docs/guides/generate.md | 2 +- docs/Beck.Docs/Content/docs/guides/styles.md | 2 +- docs/Beck.Docs/Content/docs/guides/theme.md | 2 +- docs/Beck.Docs/Content/docs/reference/yaml.md | 37 ++ docs/Beck.Docs/Content/for-ai.llms.md | 51 ++- .../wwwroot/examples/chart-bar.beck.yaml | 13 + .../wwwroot/examples/chart-donut.beck.yaml | 16 + .../wwwroot/examples/chart-line.beck.yaml | 10 + .../wwwroot/examples/chart-pie.beck.yaml | 12 + .../wwwroot/examples/chart-scatter.beck.yaml | 13 + .../examples/guides/chart-01.beck.yaml | 13 + .../examples/guides/chart-02.beck.yaml | 10 + .../examples/guides/chart-03.beck.yaml | 16 + .../examples/guides/chart-04.beck.yaml | 12 + src/Beck/Authoring/ChartBuilder.cs | 205 +++++++++ src/Beck/Authoring/Enums.cs | 44 ++ src/Beck/BeckSvg.cs | Bin 8278 -> 8439 bytes src/Beck/Model/ChartBuilder.cs | 158 +++++++ src/Beck/Model/Defaults.cs | 3 + src/Beck/Model/Schema.cs | 40 ++ src/Beck/Model/Tokens.cs | 33 +- src/Beck/Model/Validate.cs | 1 + src/Beck/Svg/ChartColors.cs | 86 ++++ src/Beck/Svg/ChartPainter.cs | 401 ++++++++++++++++++ src/Beck/Svg/SvgRenderer.cs | 10 + tests/Beck.Tests/ChartTests.cs | 260 ++++++++++++ tests/Beck.Tests/CleanLines/ChartQuality.cs | 89 ++++ tests/Beck.Tests/CleanLines/CleanLineTests.cs | 54 +++ tests/Beck.Tests/CleanLines/DiagramFuzzer.cs | 126 ++++++ tools/oracle/rendered/flowchart-branchy.svg | 1 + tools/oracle/rendered/flowchart-simple.svg | 1 + tools/oracle/rendered/mindmap-kitchen.svg | 1 + tools/oracle/rendered/mindmap-simple.svg | 1 + tools/oracle/rendered/mindmap-status.svg | 1 + 40 files changed, 1846 insertions(+), 19 deletions(-) create mode 100644 docs/Beck.Docs/Content/docs/guides/chart.md create mode 100644 docs/Beck.Docs/wwwroot/examples/chart-bar.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/chart-donut.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/chart-line.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/chart-pie.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/chart-scatter.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/guides/chart-01.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/guides/chart-02.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/guides/chart-03.beck.yaml create mode 100644 docs/Beck.Docs/wwwroot/examples/guides/chart-04.beck.yaml create mode 100644 src/Beck/Authoring/ChartBuilder.cs create mode 100644 src/Beck/Model/ChartBuilder.cs create mode 100644 src/Beck/Svg/ChartColors.cs create mode 100644 src/Beck/Svg/ChartPainter.cs create mode 100644 tests/Beck.Tests/ChartTests.cs create mode 100644 tests/Beck.Tests/CleanLines/ChartQuality.cs create mode 100644 tools/oracle/rendered/flowchart-branchy.svg create mode 100644 tools/oracle/rendered/flowchart-simple.svg create mode 100644 tools/oracle/rendered/mindmap-kitchen.svg create mode 100644 tools/oracle/rendered/mindmap-simple.svg create mode 100644 tools/oracle/rendered/mindmap-status.svg diff --git a/.gitignore b/.gitignore index d738e03..aa1760a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ bin/ obj/ *.nupkg +*.snupkg + +# Local pack output +/dist/ # Local dogfooding / verification scratch (not product code) /scratch/ @@ -20,3 +24,4 @@ docs/Beck.Docs/output/ # IDE .idea/ .vs/ +*.user diff --git a/docs/Beck.Docs.Client/PlaygroundIsland.razor b/docs/Beck.Docs.Client/PlaygroundIsland.razor index bc1193f..afe6224 100644 --- a/docs/Beck.Docs.Client/PlaygroundIsland.razor +++ b/docs/Beck.Docs.Client/PlaygroundIsland.razor @@ -534,6 +534,13 @@ new Ex("Product roadmap", "Branch statuses and a ghosted future launch", "/examples/mindmap-roadmap.beck.yaml"), new Ex("How Beck works", "The engine as a topic tree, with items and body copy", "/examples/mindmap-beck.beck.yaml"), new Ex("Frontend roadmap", "A wide learning path across the accent cycle", "/examples/mindmap-skills.beck.yaml") + ]), + new("chart", "Charts", "▦", "chart", [ + new Ex("Revenue by region", "A bar chart with an analogous palette derived from the primary", "/examples/chart-bar.beck.yaml"), + new Ex("This year vs last", "Two lines in complementary hues — a two-way comparison", "/examples/chart-line.beck.yaml"), + new Ex("Storage by tier", "A pie chart in monochromatic tints of one hue", "/examples/chart-pie.beck.yaml"), + new Ex("Requests by service", "A donut with a centre total and values in the legend", "/examples/chart-donut.beck.yaml"), + new Ex("Latency vs throughput", "A scatter plot on a sequential primary-to-neutral ramp", "/examples/chart-scatter.beck.yaml") ]) ]; diff --git a/docs/Beck.Docs/BrandStyling.cs b/docs/Beck.Docs/BrandStyling.cs index 8bb795b..739fe39 100644 --- a/docs/Beck.Docs/BrandStyling.cs +++ b/docs/Beck.Docs/BrandStyling.cs @@ -305,12 +305,14 @@ items and several are toggled from JS (is-open / is-active). */ .pg-pill[data-type="class"] { color: #6d28d9; background: color-mix(in srgb, #8b5cf6 16%, transparent); border-color: color-mix(in srgb, #8b5cf6 32%, transparent); } .pg-pill[data-type="flowchart"] { color: #0f766e; background: color-mix(in srgb, #14b8a6 15%, transparent); border-color: color-mix(in srgb, #14b8a6 30%, transparent); } .pg-pill[data-type="mindmap"] { color: #be123c; background: color-mix(in srgb, #f43f5e 14%, transparent); border-color: color-mix(in srgb, #f43f5e 30%, transparent); } + .pg-pill[data-type="chart"] { color: #0e7490; background: color-mix(in srgb, #06b6d4 15%, transparent); border-color: color-mix(in srgb, #06b6d4 30%, transparent); } .dark .pg-pill[data-type="architecture"] { color: var(--color-emerald-300, #6ee7b7); } .dark .pg-pill[data-type="sequence"] { color: var(--color-sky-300, #7dd3fc); } .dark .pg-pill[data-type="state"] { color: #fcd34d; } .dark .pg-pill[data-type="class"] { color: #c4b5fd; } .dark .pg-pill[data-type="flowchart"] { color: #5eead4; } .dark .pg-pill[data-type="mindmap"] { color: #fda4af; } + .dark .pg-pill[data-type="chart"] { color: #67e8f9; } /* Colour-scheme swatch: a two-tone chip previewing each palette's signature. */ .pg-swatch { flex: none; width: 15px; height: 15px; border-radius: 4px; border: 1px solid rgb(0 0 0 / .18); } diff --git a/docs/Beck.Docs/Content/docs/guides/chart.md b/docs/Beck.Docs/Content/docs/guides/chart.md new file mode 100644 index 0000000..5306927 --- /dev/null +++ b/docs/Beck.Docs/Content/docs/guides/chart.md @@ -0,0 +1,123 @@ +--- +title: Draw a data chart +description: Bar, line, pie, donut, and scatter charts whose whole colour set is derived from one primary token — re-tinting with your palette and flipping light and dark. +order: 31 +sectionLabel: Other diagram types +uid: docs.guide.chart +--- + +A `type: chart` document draws a small data chart — a **bar**, **line**, **pie**, **donut**, or +**scatter** — to round out a diagram with the numbers behind it. Charts are deliberately simple: no +axes to configure, no data toolkit, and no animation. What they *do* carry is Beck's colour idea taken +to its conclusion — **every series colour is derived from `--beck-primary`** by a pure +`color-mix`/relative-colour expression, so the whole set re-tints with your palette and flips light↔dark +on the same switch as the rest of the page. Swap the primary and every bar, line, slice, and dot +follows. + +## The shape: chart kind and series + +Set the `chart` kind, then list a `series`. What each series carries depends on the kind: a single +`value` for a bar or a pie/donut slice, a list of `values` for a line, or a list of `[x, y]` `points` +for a scatter. That's the whole schema — colours, spacing, and the legend are derived for you. + +```yaml:symbol +wwwroot/examples/guides/chart-01.beck.yaml +``` + +```beck:symbol,static +wwwroot/examples/guides/chart-01.beck.yaml +``` + +A bar chart colours each bar from the palette and prints its value above it; the legend maps the +colours back to labels. Any series can pin its own colour with `color:` (a token like `info` or a raw +CSS colour) to break out of the derived set. + +## Colour palettes + +`palette:` picks how the colours beyond the first are generated from `--beck-primary`. Each is a pure +function of that one token, so a chart needs no colour list of its own: + +| palette | how it derives | best for | +|---|---|---| +| `analogous` *(default)* | small hue steps either side of the primary | categorical series — distinct yet harmonious | +| `monochromatic` | tints of the primary, mixed toward the surface | an ordered magnitude, single-hue | +| `complementary` | the primary alternating with its opposite, lightening per pair | a two-way comparison | +| `sequential` | the primary fading toward neutral | one continuous scale — density or heat | + +Because the colours are expressions over the tokens rather than baked hex, they re-tint with the host +palette and adapt to dark mode automatically — see [Match your theme and +colours](/docs/guides/theme). Here `complementary` sets two lines against each other: + +```yaml:symbol +wwwroot/examples/guides/chart-02.beck.yaml +``` + +```beck:symbol,static +wwwroot/examples/guides/chart-02.beck.yaml +``` + +## Lines, scatters, and centred donuts + +A **line** series is a list of `values`, one per x-step; lines share a light gridline backdrop and a +dot on the latest point. A **scatter** series is a list of `[x, y]` `points`, one colour per series +(cluster). A **pie** is a filled wedge per slice; a **donut** is the same with a hole, and it can carry +a `center` headline and a `centerLabel` sub-caption: + +```yaml:symbol +wwwroot/examples/guides/chart-04.beck.yaml +``` + +```beck:symbol,static +wwwroot/examples/guides/chart-04.beck.yaml +``` + +```yaml:symbol +wwwroot/examples/guides/chart-03.beck.yaml +``` + +```beck:symbol,static +wwwroot/examples/guides/chart-03.beck.yaml +``` + +## The legend + +`legend:` places the key `right` (the default), `top`, `bottom`, or `none`. A right-hand legend is a +column; top and bottom are centered rows that wrap. For a single-magnitude chart (bar, pie, donut) add +`legendValues: true` to print each value alongside its label in a right-hand column — as in the donut +above. A single-series chart, or one whose bars already label themselves, reads fine with `legend: +none`. + +## Generate it from your C# + +`ChartDiagramBuilder` emits the same schema from code — fix the kind at construction, then add one +`Series` per bar, line, or cluster: + +```csharp +using Beck.Authoring; + +string fence = new ChartDiagramBuilder(ChartKind.Donut, "Cloud spend by service") + .Palette(ChartPalette.Analogous) + .Legend(LegendPlacement.Right, values: true) + .Center("$128k", "total") + .Series("Compute", 52) + .Series("Storage", 34) + .Series("Network", 22) + .ToFence(); // ```beck … ``` — drop it into any Markdown page +``` + +The `Series` overloads follow the data shapes — a single value for bar/pie/donut, several for a line, +`(x, y)` tuples for a scatter: + +```csharp +new ChartDiagramBuilder(ChartKind.Line) + .Series("This quarter", 2.4, 2.7, 3.1, 3.6) // a value per x-step + .Series("Last quarter", 1.9, 2.1, 2.3, 2.8); + +new ChartDiagramBuilder(ChartKind.Scatter) + .Series("v3.4", (74, 88), (80, 95), (77, 84)); // (x, y) points +``` + +--- + +Full field tables: [chart series in the YAML schema](/docs/reference/yaml#chart-series-type-chart). +Generating one from C#: [`ChartDiagramBuilder`](/docs/guides/generate). diff --git a/docs/Beck.Docs/Content/docs/guides/custom-styles.md b/docs/Beck.Docs/Content/docs/guides/custom-styles.md index 2f41d28..7f918cc 100644 --- a/docs/Beck.Docs/Content/docs/guides/custom-styles.md +++ b/docs/Beck.Docs/Content/docs/guides/custom-styles.md @@ -1,7 +1,7 @@ --- title: Author a custom style description: Derive a BeckStyle from a built-in with a with-expression, register it, and drive it from meta.style — retinting tokens, choosing a metrics font, and composing an artwork. -order: 35 +order: 36 sectionLabel: Cross-cutting uid: docs.guide.custom-styles --- diff --git a/docs/Beck.Docs/Content/docs/guides/flow.md b/docs/Beck.Docs/Content/docs/guides/flow.md index 52bf18e..5373762 100644 --- a/docs/Beck.Docs/Content/docs/guides/flow.md +++ b/docs/Beck.Docs/Content/docs/guides/flow.md @@ -1,7 +1,7 @@ --- title: Animate the flow description: Script packets, bursts, status pills, and effects to tell a story with motion. -order: 32 +order: 33 sectionLabel: Cross-cutting uid: docs.guide.flow --- diff --git a/docs/Beck.Docs/Content/docs/guides/generate.md b/docs/Beck.Docs/Content/docs/guides/generate.md index 6558488..19a3b50 100644 --- a/docs/Beck.Docs/Content/docs/guides/generate.md +++ b/docs/Beck.Docs/Content/docs/guides/generate.md @@ -1,7 +1,7 @@ --- title: Generate diagrams from your code description: Build diagrams with the C# DiagramBuilder so they regenerate from your real model. -order: 33 +order: 34 sectionLabel: Cross-cutting uid: docs.guide.generate --- diff --git a/docs/Beck.Docs/Content/docs/guides/styles.md b/docs/Beck.Docs/Content/docs/guides/styles.md index 991d900..91e9f55 100644 --- a/docs/Beck.Docs/Content/docs/guides/styles.md +++ b/docs/Beck.Docs/Content/docs/guides/styles.md @@ -1,7 +1,7 @@ --- title: Pick a built-in style description: The nine built-in styles — classic, minimal, terminal, blueprint, glow, brutalist, sketch, extrude, and circuit — each shown across an architecture, a sequence, and a class diagram. -order: 34 +order: 35 sectionLabel: Cross-cutting uid: docs.guide.styles --- diff --git a/docs/Beck.Docs/Content/docs/guides/theme.md b/docs/Beck.Docs/Content/docs/guides/theme.md index 4b25ea2..c5b34ac 100644 --- a/docs/Beck.Docs/Content/docs/guides/theme.md +++ b/docs/Beck.Docs/Content/docs/guides/theme.md @@ -1,7 +1,7 @@ --- title: Match your theme and colours description: How diagrams adopt your palette, choosing a theme mode, and overriding tokens. -order: 31 +order: 32 sectionLabel: Cross-cutting uid: docs.guide.theme --- diff --git a/docs/Beck.Docs/Content/docs/reference/yaml.md b/docs/Beck.Docs/Content/docs/reference/yaml.md index 15f1c45..bd648da 100644 --- a/docs/Beck.Docs/Content/docs/reference/yaml.md +++ b/docs/Beck.Docs/Content/docs/reference/yaml.md @@ -345,6 +345,43 @@ from a single point on each parent. A mind map renders **static** — no packets or narration, identical to the reduced-motion frame. A `flow:` is accepted for forward-compatibility but is not animated. +## chart series (`type: chart`) + +A small, static data chart — bar, line, pie/donut, or scatter. Charts carry no nodes or edges and +share only the [`meta`](#meta) block. Every series colour is derived from `--beck-primary` by the +chosen palette, so the chart adopts your palette and flips light/dark. See the [chart +guide](/docs/guides/chart). + +Top-level keys: + +| key | type | default | description | +|---|---|---|---| +| `chart` | `bar` \| `line` \| `pie` \| `donut` \| `scatter` | `bar` | The chart kind. | +| `palette` | `analogous` \| `monochromatic` \| `complementary` \| `sequential` | `analogous` | How series colours beyond the first are derived from `--beck-primary`. | +| `legend` | `right` \| `top` \| `bottom` \| `none` | `right` | Legend placement. | +| `legendValues` | bool | `false` | Annotate each legend entry with its value (a right-hand column of single-magnitude series). | +| `center` | string | — | Pie/donut centre headline (e.g. a total). | +| `centerLabel` | string | — | Pie/donut centre sub-caption under `center`. | +| `series` | list | — | **Required.** One entry per bar, slice, line, or point cluster. | + +Each `series` entry: + +| key | type | default | description | +|---|---|---|---| +| `label` | string | `Series N` | Legend / label text. | +| `value` | number | — | A single magnitude — **bar / pie / donut**. | +| `values` | list of numbers | — | A value per x-step — **line**. | +| `points` | list of `[x, y]` pairs | — | Data points — **scatter**. | +| `color` | token or CSS colour | palette slot | Override this series' derived colour. | + +**Palettes.** `analogous` steps the hue either side of the primary (categorical, the default); +`monochromatic` mixes the primary toward the surface (ordered magnitude); `complementary` alternates +the primary with its opposite, lightening per pair (two-way comparison); `sequential` fades the +primary toward neutral (one continuous scale). Each is a pure `color-mix`/relative-colour expression +over the tokens — swap `--beck-primary` and every series follows. + +Charts render **static** — no flow or animation. + ## Icons Set a node's `icon` to one of these named keys. Many keys are aliases that share a glyph. An unknown diff --git a/docs/Beck.Docs/Content/for-ai.llms.md b/docs/Beck.Docs/Content/for-ai.llms.md index b8a9d08..e8abcaf 100644 --- a/docs/Beck.Docs/Content/for-ai.llms.md +++ b/docs/Beck.Docs/Content/for-ai.llms.md @@ -45,8 +45,9 @@ That is a complete diagram. With no `flow:` block, Beck derives a packet animati ## The document shape A Beck document is a YAML mapping that **always opens with a root `type:`** — one of -`architecture`, `sequence`, `state`, `class`, `flowchart`, or `mindmap`. The type picks the -top-level keys; `meta` and `flow` are shared by all six. For `type: architecture`: +`architecture`, `sequence`, `state`, `class`, `flowchart`, `mindmap`, or `chart`. The type picks the +top-level keys; `meta` is shared by all, and `flow` by every type except `chart` (which is static). +For `type: architecture`: ```yaml type: architecture # REQUIRED first — architecture | sequence | state | class @@ -59,8 +60,9 @@ flow: { ... } # optional — scripted animation; omit and one is derived The other types swap the middle keys: `type: sequence` uses `participants` + `messages`, `type: state` uses `states` + `transitions`, `type: class` uses `classes` + `relations` (+ -`groups`), `type: flowchart` uses `steps` + `links`, `type: mindmap` uses `root` + `topics`. Their -cheat-sheets are [below](#the-other-diagram-types-sequence-state-class-flowchart-mindmap). +`groups`), `type: flowchart` uses `steps` + `links`, `type: mindmap` uses `root` + `topics`, and +`type: chart` uses `series`. Their +cheat-sheets are [below](#the-other-diagram-types-sequence-state-class-flowchart-mindmap-chart). Inline (`{ }` / `[ ]`) and block YAML are both fine. Use whichever is clearer. @@ -154,12 +156,12 @@ For anything else, pass raw inline `` using `fill="currentColor"`/ and a `0 0 24 24` viewBox so it inherits the node's accent and theme. Full live catalogue: [Icons reference](/docs/reference/icons). -## The other diagram types: sequence, state, class, flowchart, mindmap +## The other diagram types: sequence, state, class, flowchart, mindmap, chart -All five share `meta`, `flow`, colours, and theming with architecture diagrams. Only the middle -keys differ. Full tables: [YAML schema](/docs/reference/yaml); guides: +All share `meta`, colours, and theming with architecture diagrams (charts are static — no `flow`). +Only the middle keys differ. Full tables: [YAML schema](/docs/reference/yaml); guides: [sequence](/docs/guides/sequence) · [state](/docs/guides/state) · [class](/docs/guides/class) · -[flowchart](/docs/guides/flowchart) · [mindmap](/docs/guides/mindmap). +[flowchart](/docs/guides/flowchart) · [mindmap](/docs/guides/mindmap) · [chart](/docs/guides/chart). ### `type: sequence` — participants + messages @@ -286,6 +288,30 @@ topics: children: [Beck, Beck.Skia] ``` +### `type: chart` — series + +A small, static data chart: `chart:` is `bar`, `line`, `pie`, `donut`, or `scatter`. `series:` is +required — one entry per bar/slice (`value:`), line (`values: [...]`), or scatter cluster +(`points: [[x, y], ...]`); any series may set its own `color:`. Series colours are derived from +`--beck-primary` by `palette:` (`analogous` default, `monochromatic`, `complementary`, `sequential`) +— pure token expressions, so they re-tint with the host palette and flip light/dark. `legend:` is +`right` (default), `top`, `bottom`, or `none`; `legendValues: true` prints values in a right-hand +legend; `center` / `centerLabel` set a donut's centre. No `flow` — charts don't animate. + +```beck +type: chart +chart: donut +palette: analogous +legend: right +legendValues: true +center: 134M +centerLabel: total +series: + - { label: Gateway, value: 42 } + - { label: Catalog, value: 33 } + - { label: Checkout, value: 28 } +``` + ## Flow (animation) A `flow` is an ordered list of single-key step mappings the engine compiles into a timeline and @@ -391,14 +417,15 @@ Each diagram type has its own builder: `DiagramBuilder` (architecture), (`Class`/`Inherits`/`Composition`/…, plus **`ClassDiagramBuilder.FromTypes(typeof(…), …)`**, which reflects real CLR types into an always-current class diagram — base types become `inherits`, interfaces `implements`, property types labelled associations), `FlowchartDiagramBuilder` -(`Step`/`Process`/`Decision`/`Terminator`/`Io`/`Start`/`End`/`Link`), and `MindMapDiagramBuilder` -(`Root`/`Topic`, with `Topic` nestable to any depth for `children`). +(`Step`/`Process`/`Decision`/`Terminator`/`Io`/`Start`/`End`/`Link`), `MindMapDiagramBuilder` +(`Root`/`Topic`, with `Topic` nestable to any depth for `children`), and `ChartDiagramBuilder` +(`Palette`/`Legend`/`Center`/`Series`, one `Series` per bar/line/point-cluster). C# enums map to schema tokens (lowercased), with one special case: `EdgeCurve.StepRound` → `step-round`. `Direction` stays uppercase. Enums available: `Direction`, `NodeKind`, `NodeVariant`, `EdgeStyle`, `EdgeCurve`, `EdgeKind`, `RelationKind`, `PacketEase`, `PacketShape`, `ThemeMode`, -`FitMode`, `AccentToken`, `Side`, `ArrowEnds`. The builders throw if an edge/message/relation -references an undeclared id. Full surface: [API reference](/api) · tutorial: [Author a diagram in +`FitMode`, `AccentToken`, `Side`, `ArrowEnds`, `ChartKind`, `ChartPalette`, `LegendPlacement`. The +builders throw if an edge/message/relation references an undeclared id. Full surface: [API reference](/api) · tutorial: [Author a diagram in C#](/docs/tutorials/csharp) · keeping diagrams in sync with your model: [Generate diagrams from your code](/docs/guides/generate). diff --git a/docs/Beck.Docs/wwwroot/examples/chart-bar.beck.yaml b/docs/Beck.Docs/wwwroot/examples/chart-bar.beck.yaml new file mode 100644 index 0000000..b445d61 --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/chart-bar.beck.yaml @@ -0,0 +1,13 @@ +type: chart +meta: + title: Revenue by region + subtitle: Trailing twelve months, in millions +chart: bar +palette: analogous +legend: right +series: + - { label: North America, value: 42 } + - { label: EMEA, value: 33 } + - { label: APAC, value: 28 } + - { label: SE Asia, value: 19 } + - { label: LATAM, value: 12 } diff --git a/docs/Beck.Docs/wwwroot/examples/chart-donut.beck.yaml b/docs/Beck.Docs/wwwroot/examples/chart-donut.beck.yaml new file mode 100644 index 0000000..d21413a --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/chart-donut.beck.yaml @@ -0,0 +1,16 @@ +type: chart +meta: + title: Requests by service + subtitle: Last 24 hours +chart: donut +palette: analogous +legend: right +legendValues: true +center: 134M +centerLabel: total +series: + - { label: Gateway, value: 42 } + - { label: Catalog, value: 33 } + - { label: Checkout, value: 28 } + - { label: Search, value: 19 } + - { label: Accounts, value: 12 } diff --git a/docs/Beck.Docs/wwwroot/examples/chart-line.beck.yaml b/docs/Beck.Docs/wwwroot/examples/chart-line.beck.yaml new file mode 100644 index 0000000..a96e02e --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/chart-line.beck.yaml @@ -0,0 +1,10 @@ +type: chart +meta: + title: Signups, this year vs last + subtitle: Monthly, first half — a two-way comparison +chart: line +palette: complementary +legend: top +series: + - { label: This year, values: [30, 38, 42, 51, 58, 67] } + - { label: Last year, values: [24, 27, 33, 36, 40, 45] } diff --git a/docs/Beck.Docs/wwwroot/examples/chart-pie.beck.yaml b/docs/Beck.Docs/wwwroot/examples/chart-pie.beck.yaml new file mode 100644 index 0000000..ea2cec2 --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/chart-pie.beck.yaml @@ -0,0 +1,12 @@ +type: chart +meta: + title: Storage by tier + subtitle: Tints of one hue for an ordered magnitude +chart: pie +palette: monochromatic +legend: right +series: + - { label: Hot, value: 48 } + - { label: Warm, value: 27 } + - { label: Cold, value: 15 } + - { label: Archive, value: 10 } diff --git a/docs/Beck.Docs/wwwroot/examples/chart-scatter.beck.yaml b/docs/Beck.Docs/wwwroot/examples/chart-scatter.beck.yaml new file mode 100644 index 0000000..14ca9a1 --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/chart-scatter.beck.yaml @@ -0,0 +1,13 @@ +type: chart +meta: + title: Latency vs throughput + subtitle: Each point a node, coloured by region +chart: scatter +palette: sequential +legend: bottom +series: + - { label: us-east, points: [[20, 72], [26, 80], [31, 68], [24, 88]] } + - { label: us-west, points: [[38, 60], [44, 66], [41, 54], [49, 62]] } + - { label: eu-west, points: [[55, 50], [60, 44], [52, 58], [64, 52]] } + - { label: ap-south, points: [[70, 38], [76, 44], [72, 30], [80, 40]] } + - { label: sa-east, points: [[86, 24], [92, 30], [88, 18], [95, 26]] } diff --git a/docs/Beck.Docs/wwwroot/examples/guides/chart-01.beck.yaml b/docs/Beck.Docs/wwwroot/examples/guides/chart-01.beck.yaml new file mode 100644 index 0000000..c41069b --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/guides/chart-01.beck.yaml @@ -0,0 +1,13 @@ +type: chart +meta: + title: Requests by endpoint + subtitle: Peak hour, thousands per minute +chart: bar +palette: analogous +legend: right +series: + - { label: /search, value: 48 } + - { label: /catalog, value: 36 } + - { label: /cart, value: 24 } + - { label: /checkout, value: 15 } + - { label: /account, value: 9 } diff --git a/docs/Beck.Docs/wwwroot/examples/guides/chart-02.beck.yaml b/docs/Beck.Docs/wwwroot/examples/guides/chart-02.beck.yaml new file mode 100644 index 0000000..9c684b0 --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/guides/chart-02.beck.yaml @@ -0,0 +1,10 @@ +type: chart +meta: + title: Conversion, this quarter vs last + subtitle: Weekly, as a percentage +chart: line +palette: complementary +legend: top +series: + - { label: This quarter, values: [2.4, 2.7, 3.1, 3.0, 3.6, 4.1] } + - { label: Last quarter, values: [1.9, 2.1, 2.3, 2.6, 2.8, 3.0] } diff --git a/docs/Beck.Docs/wwwroot/examples/guides/chart-03.beck.yaml b/docs/Beck.Docs/wwwroot/examples/guides/chart-03.beck.yaml new file mode 100644 index 0000000..d58b269 --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/guides/chart-03.beck.yaml @@ -0,0 +1,16 @@ +type: chart +meta: + title: Cloud spend by service + subtitle: This month +chart: donut +palette: analogous +legend: right +legendValues: true +center: $128k +centerLabel: total +series: + - { label: Compute, value: 52 } + - { label: Storage, value: 34 } + - { label: Network, value: 22 } + - { label: Database, value: 14 } + - { label: Other, value: 6 } diff --git a/docs/Beck.Docs/wwwroot/examples/guides/chart-04.beck.yaml b/docs/Beck.Docs/wwwroot/examples/guides/chart-04.beck.yaml new file mode 100644 index 0000000..8190bf7 --- /dev/null +++ b/docs/Beck.Docs/wwwroot/examples/guides/chart-04.beck.yaml @@ -0,0 +1,12 @@ +type: chart +meta: + title: Response time under load + subtitle: Each point a load test, grouped by build +chart: scatter +palette: sequential +legend: bottom +series: + - { label: v3.1, points: [[10, 42], [16, 48], [12, 39]] } + - { label: v3.2, points: [[30, 55], [36, 61], [33, 50]] } + - { label: v3.3, points: [[52, 70], [58, 66], [55, 78]] } + - { label: v3.4, points: [[74, 88], [80, 95], [77, 84]] } diff --git a/src/Beck/Authoring/ChartBuilder.cs b/src/Beck/Authoring/ChartBuilder.cs new file mode 100644 index 0000000..fc0dd3a --- /dev/null +++ b/src/Beck/Authoring/ChartBuilder.cs @@ -0,0 +1,205 @@ +using System.Globalization; +using System.Text; + +namespace Beck.Authoring; + +/// +/// Builds a type: chart Beck diagram — a small, static data chart (bar, line, pie/donut, or +/// scatter). The chart kind is fixed at construction; add one per +/// bar/slice, per line, or per point-cluster. Series colours are derived from --beck-primary by +/// the chosen unless a series pins its own colour, so the whole chart re-tints +/// with the host palette and flips light↔dark like every other Beck diagram. +/// +/// +/// +/// var fence = new ChartDiagramBuilder(ChartKind.Donut, "Requests by service") +/// .Palette(ChartPalette.Analogous) +/// .Legend(LegendPlacement.Right, values: true) +/// .Center("134M", "total") +/// .Series("Gateway", 42) +/// .Series("Catalog", 33) +/// .Series("Checkout", 28) +/// .ToFence(); +/// +/// +public sealed class ChartDiagramBuilder +{ + private readonly MetaOptions _meta = new(); + private readonly ChartKind _kind; + private readonly List _series = new(); + private ChartPalette? _palette; + private LegendPlacement? _legend; + private bool _legendValues; + private string? _center; + private string? _centerLabel; + + /// Create a chart of the given . + public ChartDiagramBuilder(ChartKind kind) => _kind = kind; + + /// Create a chart of the given with a title. + public ChartDiagramBuilder(ChartKind kind, string title) : this(kind) => _meta._title = title; + + /// Set the diagram title. + public ChartDiagramBuilder Title(string title) { _meta._title = title; return this; } + + /// Set the diagram subtitle. + public ChartDiagramBuilder Subtitle(string subtitle) { _meta._subtitle = subtitle; return this; } + + /// Set the visual style by its meta.style token (e.g. "classic"). + public ChartDiagramBuilder Style(string name) { _meta._style = name; return this; } + + /// Set the visual style from a (emits its ). + public ChartDiagramBuilder Style(BeckStyle style) { _meta._style = style.Name; return this; } + + /// Set the theme: (default), , or . + public ChartDiagramBuilder Theme(ThemeMode theme) { _meta._theme = theme; return this; } + + /// How the chart behaves when wider than its container. + public ChartDiagramBuilder Fit(FitMode fit) { _meta._fit = fit; return this; } + + /// Choose how series colours beyond the first are derived from the primary token. + public ChartDiagramBuilder Palette(ChartPalette palette) { _palette = palette; return this; } + + /// Place the legend (default ). Set + /// to annotate each entry with its value (a right-column legend of + /// single-magnitude series only). + public ChartDiagramBuilder Legend(LegendPlacement placement, bool values = false) + { + _legend = placement; + _legendValues = values; + return this; + } + + /// Set a pie/donut centre headline and an optional sub-caption under it. + public ChartDiagramBuilder Center(string headline, string? caption = null) + { + _center = headline; + _centerLabel = caption; + return this; + } + + /// Add a single-magnitude series — one bar, or one pie/donut slice. + public ChartDiagramBuilder Series(string label, double value) => Series(label, s => s.Value(value)); + + /// Add a line series — a value per x-step. + public ChartDiagramBuilder Series(string label, params double[] values) => Series(label, s => s.Values(values)); + + /// Add a scatter series — a cluster of (x, y) points sharing one colour. + public ChartDiagramBuilder Series(string label, params (double X, double Y)[] points) => Series(label, s => s.Points(points)); + + /// Add a series and refine it via — its data shape and an optional colour. + public ChartDiagramBuilder Series(string label, Action configure) + { + var s = new ChartSeriesBuilder(); + configure(s); + _series.Add(s.ToFlow(label)); + return this; + } + + /// Render the diagram as Beck YAML. + /// The chart has no series. + public string ToYaml() + { + if (_series.Count == 0) + { + throw new InvalidOperationException("A chart needs at least one Series()."); + } + + var sb = new StringBuilder(); + sb.Append("type: chart\n"); + _meta.AppendYaml(sb); + sb.Append("chart: ").Append(Tokens.Of(_kind)).Append('\n'); + if (_palette is { } p) + { + sb.Append("palette: ").Append(Tokens.Of(p)).Append('\n'); + } + + if (_legend is { } l) + { + sb.Append("legend: ").Append(Tokens.Of(l)).Append('\n'); + } + + if (_legendValues) + { + sb.Append("legendValues: true\n"); + } + + if (_center != null) + { + sb.Append("center: ").Append(YamlWriter.Scalar(_center)).Append('\n'); + } + + if (_centerLabel != null) + { + sb.Append("centerLabel: ").Append(YamlWriter.Scalar(_centerLabel)).Append('\n'); + } + + sb.Append("series:\n"); + foreach (var s in _series) + { + sb.Append(" - ").Append(s).Append('\n'); + } + + return sb.ToString(); + } + + /// Render as a fenced ```beck Markdown block — drop it into any Markdown page and it renders to a static SVG. + public string ToFence() => BeckMarkdown.Fence(ToYaml()); + + /// + public override string ToString() => ToYaml(); +} + +/// +/// Refines one series inside a Series(label, s => …) callback: its data shape +/// ( for bar/pie/donut, for line, for +/// scatter) and an optional colour override. +/// +public sealed class ChartSeriesBuilder +{ + private double? _single; + private List? _values; + private List<(double X, double Y)>? _points; + private string? _color; + + /// Set a single magnitude — a bar height or a pie/donut slice. + public ChartSeriesBuilder Value(double value) { _single = value; return this; } + + /// Set the line series' values, one per x-step. + public ChartSeriesBuilder Values(params double[] values) { _values = values.ToList(); return this; } + + /// Set the scatter series' (x, y) points. + public ChartSeriesBuilder Points(params (double X, double Y)[] points) { _points = points.ToList(); return this; } + + /// Override this series' colour with a semantic token (follows the theme). + public ChartSeriesBuilder Color(AccentToken token) { _color = Tokens.Of(token); return this; } + + /// Override this series' colour with a raw CSS colour. + public ChartSeriesBuilder Color(string color) { _color = color; return this; } + + private static string Num(double v) => v.ToString(CultureInfo.InvariantCulture); + + internal string ToFlow(string label) + { + var pairs = new List<(string, string)> { ("label", YamlWriter.Scalar(label)) }; + if (_single is { } v) + { + pairs.Add(("value", Num(v))); + } + else if (_values is { } vs) + { + pairs.Add(("values", YamlWriter.FlowSeq(vs.Select(Num)))); + } + else if (_points is { } ps) + { + pairs.Add(("points", YamlWriter.FlowSeq(ps.Select(p => $"[{Num(p.X)}, {Num(p.Y)}]")))); + } + + if (_color != null) + { + pairs.Add(("color", YamlWriter.Scalar(_color))); + } + + return YamlWriter.FlowMap(pairs); + } +} diff --git a/src/Beck/Authoring/Enums.cs b/src/Beck/Authoring/Enums.cs index d360147..fa86c8d 100644 --- a/src/Beck/Authoring/Enums.cs +++ b/src/Beck/Authoring/Enums.cs @@ -190,6 +190,47 @@ public enum StepKind End, } +/// The visual form of a type: chart data chart. +public enum ChartKind +{ + /// Vertical bars, one per series — a magnitude per category. + Bar, + /// Poly-lines, one per series across a shared x-axis. + Line, + /// A filled pie, one wedge per series. + Pie, + /// A ring (pie with a hole), with an optional centre label. + Donut, + /// An x/y scatter, one colour per series (point cluster). + Scatter, +} + +/// How a chart derives series colours beyond the first from --beck-primary. +public enum ChartPalette +{ + /// Small hue steps either side of the primary — the categorical default. + Analogous, + /// Tints of one hue, mixed toward the surface — ordered magnitude. + Monochromatic, + /// Primary alternating with its opposite — a two-way comparison. + Complementary, + /// Primary fading toward neutral — one continuous scale (density/heat). + Sequential, +} + +/// Where a chart's legend sits (or that there is none). +public enum LegendPlacement +{ + /// No legend. + None, + /// A centered row above the plot. + Top, + /// A column to the right of the plot (the default). + Right, + /// A centered row below the plot. + Bottom, +} + /// How two classes in a type: class diagram relate. public enum RelationKind { @@ -223,6 +264,9 @@ internal static class Tokens public static string Of(ArrowEnds a) => a.ToString().ToLowerInvariant(); public static string Of(RelationKind r) => r.ToString().ToLowerInvariant(); public static string Of(StepKind k) => k.ToString().ToLowerInvariant(); + public static string Of(ChartKind k) => k.ToString().ToLowerInvariant(); + public static string Of(ChartPalette p) => p.ToString().ToLowerInvariant(); + public static string Of(LegendPlacement l) => l.ToString().ToLowerInvariant(); public static string Of(EdgeCurve c) => c switch { diff --git a/src/Beck/BeckSvg.cs b/src/Beck/BeckSvg.cs index 19f6bc052d49e55c25b9f5cc154daa55ceb266fb..bc60afa7893c099b4450d6dc8d8c930c112feeb3 100644 GIT binary patch delta 167 zcmccS@ZE7kEl0h+zJgnRkwS7tVo^!4f<|7xLSBAKYO#K5N_uLsrb0 +/// type: chart — a small, static data chart (bar, line, pie/donut, or scatter). Charts share +/// the meta/theming shell with every other diagram but carry no nodes, edges, or flow: the builder +/// produces a and an otherwise-empty , and the +/// draws it directly (no layout/route/animate pipeline). +/// +/// +/// type: chart +/// meta: { title: Revenue by region } +/// chart: bar # bar | line | pie | donut | scatter (default bar) +/// palette: analogous # analogous | monochromatic | complementary | sequential (default analogous) +/// legend: right # right | top | bottom | none (default right) +/// legendValues: true # annotate legend entries with their value (bar/pie/donut) +/// center: 134M # pie/donut centre headline +/// centerLabel: total # pie/donut centre sub-caption +/// series: # one entry per bar / slice / line / point-cluster +/// - { label: North America, value: 42 } # bar / pie / donut: one magnitude +/// - { label: 2024, values: [30, 34, 38, 42] } # line: a value per x-step +/// - { label: Cluster A, points: [[20, 72], [26, 80]] } # scatter: [x, y] pairs +/// - { label: EMEA, value: 33, color: var(--beck-info) } # any series may pin its own colour +/// +/// +/// Every series colour is derived from --beck-primary by the chosen +/// (a pure color-mix/relative-colour expression), unless the series +/// pins an explicit color: — so the whole set re-tints with the host palette and flips +/// light↔dark on the same switch as the rest of Beck. +/// +internal static class ChartBuilder +{ + public static DiagramModel Build(IReadOnlyDictionary root) + { + var meta = Validate.BuildMeta(AsObject(root.GetValueOrDefault("meta"), "meta"), DiagramType.Chart); + // Charts ship static — the render funnels through the animation gates exactly as mindmaps do. + meta.Animate = false; + + var kind = OneOf(root.GetValueOrDefault("chart"), Tokens.ChartKind, "chart", ChartKind.Bar); + var palette = OneOf(root.GetValueOrDefault("palette"), Tokens.ChartPalette, "palette", ChartPalette.Analogous); + var legend = OneOf(root.GetValueOrDefault("legend"), Tokens.LegendPlacement, "legend", LegendPlacement.Right); + var legendValues = OptBool(root.GetValueOrDefault("legendValues"), "legendValues", false); + + var rawSeries = AsArray(root.GetValueOrDefault("series"), "series"); + if (rawSeries.Count == 0) + { + throw new BeckYamlException("A chart needs at least one entry under `series`"); + } + + var series = new List(rawSeries.Count); + for (var i = 0; i < rawSeries.Count; i++) + { + var s = AsObject(rawSeries[i], $"series[{i}]"); + var label = OptString(s.GetValueOrDefault("label")) + ?? $"Series {(i + 1).ToString(CultureInfo.InvariantCulture)}"; + + IReadOnlyList values = []; + IReadOnlyList points = []; + switch (kind) + { + case ChartKind.Scatter: + points = PointList(s.GetValueOrDefault("points"), $"series[{i}].points"); + if (points.Count == 0) + { + throw new BeckYamlException($"Scatter `series[{i}]` needs a non-empty `points` list of [x, y] pairs"); + } + + break; + case ChartKind.Line: + values = NumberList(s.GetValueOrDefault("values"), $"series[{i}].values"); + if (values.Count == 0) + { + throw new BeckYamlException($"Line `series[{i}]` needs a non-empty `values` list"); + } + + break; + default: // bar / pie / donut — a single magnitude, from `value` (or `values[0]`) + var single = OptNumber(s.GetValueOrDefault("value"), $"series[{i}].value"); + if (single is null) + { + var vs = NumberList(s.GetValueOrDefault("values"), $"series[{i}].values"); + single = vs.Count > 0 ? vs[0] : throw new BeckYamlException( + $"`series[{i}]` needs a `value` (a number)"); + } + + values = [single.Value]; + break; + } + + series.Add(new ChartSeries + { + Label = label, + Color = OptColor(s.GetValueOrDefault("color")), + Values = values, + Points = points, + }); + } + + var chart = new ChartModel + { + Kind = kind, + Palette = palette, + Legend = legend, + LegendValues = legendValues, + Series = series, + Center = OptString(root.GetValueOrDefault("center")), + CenterLabel = OptString(root.GetValueOrDefault("centerLabel")), + }; + + return new DiagramModel + { + Meta = meta, + Nodes = [], + Groups = [], + Edges = [], + Flow = new FlowModel { Repeat = 0, RepeatDelay = 0, Steps = [], Derived = false }, + Sections = [], + Chart = chart, + }; + } + + /// Parse a list of numbers (line series values). + private static List NumberList(object? v, string field) + { + var arr = AsArray(v, field); + var nums = new List(arr.Count); + for (var i = 0; i < arr.Count; i++) + { + nums.Add(OptNumber(arr[i], $"{field}[{i}]") + ?? throw new BeckYamlException($"`{field}[{i}]` must be a number")); + } + + return nums; + } + + /// Parse a list of [x, y] pairs (scatter points). + private static List PointList(object? v, string field) + { + var arr = AsArray(v, field); + var pts = new List(arr.Count); + for (var i = 0; i < arr.Count; i++) + { + var pair = AsArray(arr[i], $"{field}[{i}]"); + if (pair.Count < 2) + { + throw new BeckYamlException($"`{field}[{i}]` must be an [x, y] pair"); + } + + var x = OptNumber(pair[0], $"{field}[{i}].x") ?? throw new BeckYamlException($"`{field}[{i}].x` must be a number"); + var y = OptNumber(pair[1], $"{field}[{i}].y") ?? throw new BeckYamlException($"`{field}[{i}].y` must be a number"); + pts.Add(new ChartPoint(x, y)); + } + + return pts; + } +} diff --git a/src/Beck/Model/Defaults.cs b/src/Beck/Model/Defaults.cs index 166ab5c..36c207b 100644 --- a/src/Beck/Model/Defaults.cs +++ b/src/Beck/Model/Defaults.cs @@ -27,6 +27,9 @@ internal static class Defaults // of 50 (30px pill + 20 gap), and Rank 70 is the face-to-face gap the fixed branch cubics bow // across (controls at ±40 out of the root / ±35 out of rank 1). [DiagramType.MindMap] = new(Rank: 70, Node: 20, CornerRadius: 16), + // Charts don't use rank/node spacing (no layered layout); the entry only satisfies the + // per-type lookup in BuildMeta. CornerRadius carries through as the chart's bar radius. + [DiagramType.Chart] = new(Rank: 96, Node: 32, CornerRadius: 16), }; /// Narration is available by default; wpm/min/pad set the reading-time pace. diff --git a/src/Beck/Model/Schema.cs b/src/Beck/Model/Schema.cs index 5a948af..eb0d627 100644 --- a/src/Beck/Model/Schema.cs +++ b/src/Beck/Model/Schema.cs @@ -250,6 +250,44 @@ internal sealed record FlowModel /// A labelled horizontal band in a sequence diagram, drawn before message At. internal sealed record SectionMark(string Label, int At, string Accent); +/// An (x, y) data point for a scatter series. +internal sealed record ChartPoint(double X, double Y); + +/// +/// One data series in a type: chart chart. Which member carries the data depends on the +/// chart kind: bar/pie/donut read [0] (one magnitude per category), line reads +/// the whole list, and scatter reads . +/// overrides the derived palette slot for this series (else null → take the palette colour). +/// +internal sealed record ChartSeries +{ + public required string Label { get; init; } + /// CSS colour override (e.g. var(--beck-info) or a raw hex), or null to take the + /// palette-derived slot for this series' index. + public string? Color { get; init; } + public required IReadOnlyList Values { get; init; } + public required IReadOnlyList Points { get; init; } +} + +/// +/// A type: chart data chart. Rendered static (no flow), on its own painter that bypasses the +/// node/edge layout+route pipeline; every series colour is a expression +/// over --beck-primary, so the chart re-tints with the host palette and flips light↔dark. +/// +internal sealed record ChartModel +{ + public required ChartKind Kind { get; init; } + public required ChartPalette Palette { get; init; } + public required LegendPlacement Legend { get; init; } + /// Annotate each legend entry with its (single) value — bar/pie/donut only. + public required bool LegendValues { get; init; } + public required IReadOnlyList Series { get; init; } + /// Pie/donut centre headline (e.g. a total), or null. + public string? Center { get; init; } + /// Pie/donut centre sub-caption under , or null. + public string? CenterLabel { get; init; } +} + internal sealed record DiagramModel { public required DiagramMeta Meta { get; init; } @@ -258,4 +296,6 @@ internal sealed record DiagramModel public required IReadOnlyList Edges { get; init; } public required FlowModel Flow { get; init; } public required IReadOnlyList Sections { get; init; } + /// Chart payload for type: chart; null for every graph type. + public ChartModel? Chart { get; init; } } \ No newline at end of file diff --git a/src/Beck/Model/Tokens.cs b/src/Beck/Model/Tokens.cs index 43e5d38..183bac5 100644 --- a/src/Beck/Model/Tokens.cs +++ b/src/Beck/Model/Tokens.cs @@ -6,7 +6,16 @@ namespace Beck.Model; // downstream layout/route/render/animate stages switch exhaustively. /// What the diagram is; picks the layout + routing strategy. -internal enum DiagramType { Architecture, Sequence, State, Class, Flowchart, MindMap } +internal enum DiagramType { Architecture, Sequence, State, Class, Flowchart, MindMap, Chart } + +/// The visual form of a type: chart data chart. +internal enum ChartKind { Bar, Line, Pie, Donut, Scatter } + +/// How a chart derives series colours beyond the first from --beck-primary. +internal enum ChartPalette { Analogous, Monochromatic, Complementary, Sequential } + +/// Where a chart's legend sits (or that there is none). +internal enum LegendPlacement { None, Top, Right, Bottom } /// Primary layout axis. internal enum Direction { Tb, Bt, Lr, Rl } @@ -79,7 +88,27 @@ internal static class Tokens (Model.DiagramType.State, "state"), (Model.DiagramType.Class, "class"), (Model.DiagramType.Flowchart, "flowchart"), - (Model.DiagramType.MindMap, "mindmap")); + (Model.DiagramType.MindMap, "mindmap"), + (Model.DiagramType.Chart, "chart")); + + public static readonly TokenMap ChartKind = new( + (Model.ChartKind.Bar, "bar"), + (Model.ChartKind.Line, "line"), + (Model.ChartKind.Pie, "pie"), + (Model.ChartKind.Donut, "donut"), + (Model.ChartKind.Scatter, "scatter")); + + public static readonly TokenMap ChartPalette = new( + (Model.ChartPalette.Analogous, "analogous"), + (Model.ChartPalette.Monochromatic, "monochromatic"), + (Model.ChartPalette.Complementary, "complementary"), + (Model.ChartPalette.Sequential, "sequential")); + + public static readonly TokenMap LegendPlacement = new( + (Model.LegendPlacement.None, "none"), + (Model.LegendPlacement.Top, "top"), + (Model.LegendPlacement.Right, "right"), + (Model.LegendPlacement.Bottom, "bottom")); public static readonly TokenMap Direction = new( (Model.Direction.Tb, "TB"), diff --git a/src/Beck/Model/Validate.cs b/src/Beck/Model/Validate.cs index 1489aaf..8c607c1 100644 --- a/src/Beck/Model/Validate.cs +++ b/src/Beck/Model/Validate.cs @@ -577,6 +577,7 @@ public static DiagramModel BuildModel(object? raw) DiagramType.Class => ClassBuilder.Build(root), DiagramType.Flowchart => FlowchartBuilder.Build(root), DiagramType.MindMap => MindMapBuilder.Build(root), + DiagramType.Chart => ChartBuilder.Build(root), _ => BuildArchitectureModel(root), }; } diff --git a/src/Beck/Svg/ChartColors.cs b/src/Beck/Svg/ChartColors.cs new file mode 100644 index 0000000..ecc8684 --- /dev/null +++ b/src/Beck/Svg/ChartColors.cs @@ -0,0 +1,86 @@ +using System.Globalization; +using Beck.Model; + +namespace Beck.Svg; + +/// +/// Derives a chart's series colours from --beck-primary — the heart of the chart design +/// (Charts.dc.html). Each algorithm is a pure function of the token, emitted as a +/// color-mix / relative-colour (oklch(from …)) expression, never a resolved literal, so +/// the whole set re-tints with the host palette and flips light↔dark on the same switch as every +/// other Beck colour. A series that pins its own color: overrides its slot. +/// +internal static class ChartColors +{ + /// The series colours for , in order. + public static IReadOnlyList Palette(ChartPalette palette, int n) => palette switch + { + ChartPalette.Monochromatic => Monochromatic(n), + ChartPalette.Complementary => Complementary(n), + ChartPalette.Sequential => Sequential(n), + _ => Analogous(n), + }; + + /// Small hue steps either side of the primary — distinct yet harmonious; the categorical default. + private static List Analogous(int n) + { + var list = new List(n); + for (var i = 0; i < n; i++) + { + var d = RoundInt((i - (n - 1) / 2.0) * 26); + list.Add($"oklch(from var(--beck-primary) l c {Hue(d)})"); + } + + return list; + } + + /// Tints & shades of the primary, mixed toward the surface — calm, single-hue. + private static List Monochromatic(int n) + { + var list = new List(n); + for (var i = 0; i < n; i++) + { + var t = n == 1 ? 0 : (double)i / (n - 1); + list.Add($"color-mix(in oklab, var(--beck-primary), var(--beck-surface) {RoundInt(6 + t * 64)}%)"); + } + + return list; + } + + /// Primary alternating with its opposite, lightening per pair — high contrast. + private static List Complementary(int n) + { + var list = new List(n); + for (var i = 0; i < n; i++) + { + int side = i % 2, grp = i / 2; + var l = Math.Round(grp * 0.1, 2); + list.Add($"oklch(from var(--beck-primary) {Lightness(l)} c {Hue(side * 180)})"); + } + + return list; + } + + /// Primary fading toward neutral — reads as one continuous scale (density/heat). + private static List Sequential(int n) + { + var list = new List(n); + for (var i = 0; i < n; i++) + { + var t = n == 1 ? 0 : (double)i / (n - 1); + list.Add($"color-mix(in oklab, var(--beck-primary), var(--beck-neutral) {RoundInt(4 + t * 72)}%)"); + } + + return list; + } + + /// A relative-colour hue channel: h, or calc(h ± d) degrees off it. + private static string Hue(int d) => d == 0 ? "h" : d > 0 ? $"calc(h + {d})" : $"calc(h - {-d})"; + + /// A relative-colour lightness channel: l, or calc(l + d). + private static string Lightness(double d) => + d == 0 ? "l" : $"calc(l + {d.ToString("0.##", CultureInfo.InvariantCulture)})"; + + /// JS Math.round (half toward +∞) — the reference impl rounds mix percentages this way. + private static int RoundInt(double x) => (int)Math.Floor(x + 0.5); +} diff --git a/src/Beck/Svg/ChartPainter.cs b/src/Beck/Svg/ChartPainter.cs new file mode 100644 index 0000000..b19af49 --- /dev/null +++ b/src/Beck/Svg/ChartPainter.cs @@ -0,0 +1,401 @@ +using System.Globalization; +using System.Text; +using Beck.Model; +using Beck.Text; + +namespace Beck.Svg; + +/// +/// Draws a type: chart chart directly to SVG — bar, line, pie/donut, or scatter, plus an +/// optional legend. Charts bypass the node/edge layout+route+animate pipeline entirely: this painter +/// returns the body markup and its natural size, which drops into the shared +/// <svg> shell (title block + --beck-* token <style>). Every colour is +/// a var(--beck-*) token or a expression over --beck-primary, +/// so a chart adopts the host palette and flips light↔dark like the rest of Beck. Static — no motion. +/// +internal static class ChartPainter +{ + private const double Pad = 16; // outer margin around the whole chart + private const double Gap = 24; // between plot and legend + private const double PlotW = 400, PlotH = 250, PieSize = 260; + + private static string N(double n) => SvgWriter.Num(n); + + /// Sanitise a datum to a finite number — NaN/±∞ (e.g. from an overflowing literal like + /// 1e400) collapse to 0, so no non-finite coordinate can ever reach the SVG. + private static double Fin(double v) => double.IsFinite(v) ? v : 0; + + /// Format a value label: plain for the ordinary range, compact exponential for very large + /// or very small magnitudes so one extreme datum can't blow up the legend (and canvas) width. + private static string Fmt(double v) + { + if (!double.IsFinite(v)) + { + return "0"; + } + + var a = Math.Abs(v); + return a != 0 && (a >= 1e6 || a < 1e-3) + ? v.ToString("0.##e+0", CultureInfo.InvariantCulture) + : v.ToString("0.##", CultureInfo.InvariantCulture); + } + + /// Render the chart body and report its natural (unpadded content + outer ) size. + public static (string Markup, double Width, double Height) Render(ChartModel chart, ITextMeasurer m, bool guard) + { + var n = chart.Series.Count; + var palette = ChartColors.Palette(chart.Palette, n); + var colors = chart.Series.Select((s, i) => s.Color ?? palette[i]).ToList(); + + var pieish = chart.Kind is ChartKind.Pie or ChartKind.Donut; + double plotW = pieish ? PieSize : PlotW, plotH = pieish ? PieSize : PlotH; + var plot = chart.Kind switch + { + ChartKind.Bar => Bar(chart, colors, plotW, plotH, guard, m), + ChartKind.Line => Line(chart, colors, plotW, plotH), + ChartKind.Scatter => Scatter(chart, colors, plotW, plotH), + _ => Pie(chart, colors, plotW, plotH, chart.Kind == ChartKind.Donut, guard, m), + }; + + // Legend geometry + the block layout around the plot. + var legend = chart.Legend == LegendPlacement.None ? null : BuildLegend(chart, colors, m, guard, plotW); + double contentW, contentH, plotX, plotY; + var legendGroup = ""; + + if (legend is not { } lg) + { + contentW = plotW; + contentH = plotH; + plotX = 0; + plotY = 0; + } + else if (chart.Legend == LegendPlacement.Right) + { + contentW = plotW + Gap + lg.Width; + contentH = Math.Max(plotH, lg.Height); + plotX = 0; + plotY = (contentH - plotH) / 2; + legendGroup = Translate(plotW + Gap, (contentH - lg.Height) / 2, lg.Markup); + } + else // Top or Bottom (horizontal band; widened past the plot only if a long label overflows) + { + contentW = lg.Width; + contentH = plotH + Gap + lg.Height; + plotX = (lg.Width - plotW) / 2; + var top = chart.Legend == LegendPlacement.Top; + plotY = top ? lg.Height + Gap : 0; + legendGroup = Translate(0, top ? 0 : plotH + Gap, lg.Markup); + } + + var body = new StringBuilder(); + body.Append(""); + body.Append(Translate(plotX, plotY, plot)); + body.Append(legendGroup); + body.Append(""); + return (body.ToString(), contentW + 2 * Pad, contentH + 2 * Pad); + } + + private static string Translate(double dx, double dy, string inner) => + inner.Length == 0 ? "" : $"{inner}"; + + // ---- plots ---- + + private static string Bar(ChartModel chart, IReadOnlyList colors, double w, double h, bool guard, ITextMeasurer m) + { + double t = 18, r = 10, b = 12, l = 10; + double pw = w - l - r, ph = h - t - b; + var vals = chart.Series.Select(s => Fin(s.Values.Count > 0 ? s.Values[0] : 0)).ToList(); + var max = vals.DefaultIfEmpty(0).Max(); + if (max <= 0) + { + max = 1; + } + + var gap = pw / vals.Count; + var bw = Math.Min(gap * 0.62, 64); + var baseY = t + ph; + var spec = FontRoles.Of(FontRole.Status); + + var sb = new StringBuilder(); + sb.Append($""); + for (var i = 0; i < vals.Count; i++) + { + // Clamp the bar into the plot: a negative datum reads as an empty bar, a huge one as a + // full-height bar — never a negative rect height or an off-canvas top. + var bh = Math.Clamp(vals[i] / max * ph, 0, ph); + var x = l + gap * i + (gap - bw) / 2; + var y = baseY - bh; + sb.Append($""); + var label = Fmt(vals[i]); + var lw = m.Measure(label, FontRole.Status, spec).Width; + sb.Append($"{SvgWriter.Text(label)}"); + } + + return sb.ToString(); + } + + private static string Line(ChartModel chart, IReadOnlyList colors, double w, double h) + { + double t = 14, r = 14, b = 16, l = 16; + double pw = w - l - r, ph = h - t - b; + var cols = chart.Series.Max(s => s.Values.Count); + var denom = Math.Max(cols - 1, 1); + var max = chart.Series.SelectMany(s => s.Values).Select(Fin).DefaultIfEmpty(0).Max(); + if (max <= 0) + { + max = 1; + } + + double X(int i) => l + pw / denom * i; + double Y(double v) => Math.Clamp(t + ph - Fin(v) / max * ph, t, t + ph); + + var sb = new StringBuilder(); + sb.Append(Grid(w, l, r, t, ph)); + for (var si = 0; si < chart.Series.Count; si++) + { + var s = chart.Series[si]; + if (s.Values.Count == 0) + { + continue; + } + + var pts = string.Join(" ", s.Values.Select((v, i) => $"{N(X(i))},{N(Y(v))}")); + sb.Append($""); + var last = s.Values.Count - 1; + sb.Append($""); + } + + return sb.ToString(); + } + + private static string Scatter(ChartModel chart, IReadOnlyList colors, double w, double h) + { + double t = 14, r = 14, b = 14, l = 16; + double pw = w - l - r, ph = h - t - b; + var flat = chart.Series.SelectMany(s => s.Points).ToList(); + var mx = flat.Select(p => Fin(p.X)).DefaultIfEmpty(1).Max(); + var my = flat.Select(p => Fin(p.Y)).DefaultIfEmpty(1).Max(); + if (mx <= 0) + { + mx = 1; + } + + if (my <= 0) + { + my = 1; + } + + // Clamp each point into the plot rect: an out-of-range (negative or huge) coordinate lands on + // the border rather than off-canvas. + double X(double x) => Math.Clamp(l + Fin(x) / (mx * 1.05) * pw, l, w - r); + double Y(double y) => Math.Clamp(t + ph - Fin(y) / (my * 1.05) * ph, t, t + ph); + + var sb = new StringBuilder(); + sb.Append(Grid(w, l, r, t, ph)); + for (var si = 0; si < chart.Series.Count; si++) + { + foreach (var p in chart.Series[si].Points) + { + sb.Append($""); + } + } + + return sb.ToString(); + } + + private static string Pie(ChartModel chart, IReadOnlyList colors, double w, double h, bool donut, bool guard, ITextMeasurer m) + { + double cx = w / 2, cy = h / 2; + var rOuter = Math.Min(w, h) / 2 - 6; + var rInner = donut ? rOuter * 0.58 : 0; + // A slice can only be a non-negative magnitude — negatives and non-finite data collapse to 0 + // so no wedge sweeps a backwards or non-finite angle. + var vals = chart.Series.Select(s => Math.Max(0, Fin(s.Values.Count > 0 ? s.Values[0] : 0))).ToList(); + var total = vals.Sum(); + if (total <= 0) + { + total = 1; + } + + var sb = new StringBuilder(); + // A single slice is a full ring/disc — the arc would be zero-length (start == end), so draw circles. + if (vals.Count == 1) + { + sb.Append($""); + if (donut) + { + sb.Append($""); + } + } + else + { + var ang = -Math.PI / 2; + for (var i = 0; i < vals.Count; i++) + { + double a0 = ang, a1 = ang + vals[i] / total * Math.PI * 2; + ang = a1; + var (x0, y0) = Pol(cx, cy, rOuter, a0); + var (x1, y1) = Pol(cx, cy, rOuter, a1); + var large = a1 - a0 > Math.PI ? 1 : 0; + string d; + if (donut) + { + var (xi1, yi1) = Pol(cx, cy, rInner, a1); + var (xi0, yi0) = Pol(cx, cy, rInner, a0); + d = $"M{N(x0)} {N(y0)} A{N(rOuter)} {N(rOuter)} 0 {large} 1 {N(x1)} {N(y1)} L{N(xi1)} {N(yi1)} A{N(rInner)} {N(rInner)} 0 {large} 0 {N(xi0)} {N(yi0)} Z"; + } + else + { + d = $"M{N(cx)} {N(cy)} L{N(x0)} {N(y0)} A{N(rOuter)} {N(rOuter)} 0 {large} 1 {N(x1)} {N(y1)} Z"; + } + + sb.Append($""); + } + } + + if (chart.Center is { } center) + { + var hasSub = chart.CenterLabel != null; + var cSpec = FontRoles.Of(FontRole.DiagramTitle); + var cw = m.Measure(center, FontRole.DiagramTitle, cSpec).Width; + sb.Append($"{SvgWriter.Text(center)}"); + if (chart.CenterLabel is { } sub) + { + sb.Append($"{SvgWriter.Text(sub.ToUpperInvariant())}"); + } + } + + return sb.ToString(); + } + + /// Four evenly-spaced horizontal gridlines (top through baseline), for line/scatter plots. + private static string Grid(double w, double l, double r, double t, double ph) + { + var sb = new StringBuilder(); + for (var g = 0; g <= 3; g++) + { + var y = t + ph / 3 * g; + sb.Append($""); + } + + return sb.ToString(); + } + + private static (double X, double Y) Pol(double cx, double cy, double r, double a) => + (cx + r * Math.Cos(a), cy + r * Math.Sin(a)); + + // ---- legend ---- + + private sealed record Legend(string Markup, double Width, double Height); + + private static Legend BuildLegend(ChartModel chart, IReadOnlyList colors, ITextMeasurer m, bool guard, double plotW) + { + var labelSpec = FontRoles.Of(FontRole.CardSubtitle); + var valueSpec = FontRoles.Of(FontRole.MsgText); + const double Sw = 11, SwGap = 8, RowH = 22; + + // Inline values only make sense for a single-magnitude series (bar/pie/donut) in a column legend. + var showValues = chart.LegendValues && chart.Legend == LegendPlacement.Right + && chart.Series.All(s => s.Values.Count == 1); + + var labels = chart.Series.Select(s => s.Label).ToList(); + var labelWs = labels.Select(t => m.Measure(t, FontRole.CardSubtitle, labelSpec).Width).ToList(); + + string Swatch(double y, string color) => + $""; + + string LabelText(double x, double y, string text, double tw) => + $"{SvgWriter.Text(text)}"; + + if (chart.Legend == LegendPlacement.Right) + { + var maxLabel = labelWs.DefaultIfEmpty(0).Max(); + var textX = Sw + SwGap; + double valueColW = 0, valueX = 0; + List? valueStrs = null; + if (showValues) + { + valueStrs = chart.Series.Select(s => Fmt(Fin(s.Values[0]))).ToList(); + valueColW = valueStrs.Select(v => m.Measure(v, FontRole.MsgText, valueSpec).Width).DefaultIfEmpty(0).Max(); + valueX = textX + maxLabel + 16 + valueColW; + } + + var colW = textX + maxLabel + (showValues ? 16 + valueColW : 0); + var sb = new StringBuilder(); + for (var i = 0; i < labels.Count; i++) + { + var y = i * RowH; + sb.Append(Swatch(y, colors[i])); + sb.Append(LabelText(textX, y, labels[i], labelWs[i])); + if (showValues && valueStrs != null) + { + var vw = m.Measure(valueStrs[i], FontRole.MsgText, valueSpec).Width; + sb.Append($"{SvgWriter.Text(valueStrs[i])}"); + } + } + + return new Legend(sb.ToString(), colW, labels.Count * RowH); + } + + // Top / bottom: a centered, wrapping row of swatch+label chips within the plot width. + const double EntryGap = 18; + var entryWs = labelWs.Select(lw => Sw + SwGap + lw).ToList(); + var rows = new List>(); + var cur = new List(); + double curW = 0; + for (var i = 0; i < entryWs.Count; i++) + { + var add = cur.Count == 0 ? entryWs[i] : EntryGap + entryWs[i]; + if (cur.Count > 0 && curW + add > plotW) + { + rows.Add(cur); + cur = new List(); + curW = 0; + add = entryWs[i]; + } + + cur.Add(i); + curW += add; + } + + if (cur.Count > 0) + { + rows.Add(cur); + } + + // A single entry can be wider than the plot (a very long label can't wrap); widen the whole + // block to the widest row so every row still centres on-canvas. + double RowWidth(List row) => row.Sum(i => entryWs[i]) + EntryGap * (row.Count - 1); + var legendW = Math.Max(plotW, rows.Count == 0 ? 0 : rows.Max(RowWidth)); + var markup = RowLegend(rows, entryWs, labels, labelWs, colors, guard, legendW, Sw, SwGap, RowH, EntryGap, labelSpec.SizePx); + return new Legend(markup, legendW, rows.Count * RowH); + } + + /// Emit the wrapping row legend with each entry placed at its absolute x (swatch + label together). + private static string RowLegend(List> rows, List entryWs, List labels, List labelWs, + IReadOnlyList colors, bool guard, double plotW, double sw, double swGap, double rowH, double entryGap, double labelSize) + { + var sb = new StringBuilder(); + for (var rIdx = 0; rIdx < rows.Count; rIdx++) + { + var row = rows[rIdx]; + var rowW = row.Sum(i => entryWs[i]) + entryGap * (row.Count - 1); + var x = (plotW - rowW) / 2; + var y = rIdx * rowH; + foreach (var i in row) + { + sb.Append($""); + sb.Append($"{SvgWriter.Text(labels[i])}"); + x += entryWs[i] + entryGap; + } + } + + return sb.ToString(); + } +} diff --git a/src/Beck/Svg/SvgRenderer.cs b/src/Beck/Svg/SvgRenderer.cs index 0a0d836..180b727 100644 --- a/src/Beck/Svg/SvgRenderer.cs +++ b/src/Beck/Svg/SvgRenderer.cs @@ -93,6 +93,16 @@ public static string Render(DiagramModel model, ITextMeasurer measurer, string h body.Append(""); } + else if (model.Meta.Type == DiagramType.Chart) + { + // Charts skip the whole node/edge/flow pipeline: the painter draws straight to SVG, and a + // dummy LayoutResult carries only its size into the shared shell (title + token Request Handlingyesnogive upRead requestValidate inputValid?Process requestFix inputGive upNotify caller \ No newline at end of file diff --git a/tools/oracle/rendered/flowchart-simple.svg b/tools/oracle/rendered/flowchart-simple.svg new file mode 100644 index 0000000..e285f86 --- /dev/null +++ b/tools/oracle/rendered/flowchart-simple.svg @@ -0,0 +1 @@ +Deploy PipelineBuild artifact \ No newline at end of file diff --git a/tools/oracle/rendered/mindmap-kitchen.svg b/tools/oracle/rendered/mindmap-kitchen.svg new file mode 100644 index 0000000..db07ffd --- /dev/null +++ b/tools/oracle/rendered/mindmap-kitchen.svg @@ -0,0 +1 @@ +Beck ArchitectureBeckserver-side diagramsRenderingPipeline• Model• Text• Layout• Route• Svg• AnimateDeterminismSame YAML and the same options alwaysproduce byte-identical SVG, ids from a contenthash.Diagram TypesArchitectureSequenceStateClassPackagesBeck• engine• authoringBeck.SkiaHarfBuzz shapingFont metricsAuthoringDiagramBuilderYamlWriterThemingCSS variablesDark mode \ No newline at end of file diff --git a/tools/oracle/rendered/mindmap-simple.svg b/tools/oracle/rendered/mindmap-simple.svg new file mode 100644 index 0000000..fa11523 --- /dev/null +++ b/tools/oracle/rendered/mindmap-simple.svg @@ -0,0 +1 @@ +Beck OverviewBeckRenderingLayoutRoutingPackagesEngineSkia \ No newline at end of file diff --git a/tools/oracle/rendered/mindmap-status.svg b/tools/oracle/rendered/mindmap-status.svg new file mode 100644 index 0000000..e6673eb --- /dev/null +++ b/tools/oracle/rendered/mindmap-status.svg @@ -0,0 +1 @@ +Platform RedesignPlatform RedesignQ3 initiativeResearchcompleteUser interviewsAnalytics auditDesignDesign tokensComponent libraryPrototypeEngineeringin progressAPI migrationFrontend rebuildLaunchplannedBeta programMarketing site \ No newline at end of file