reladraw 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,28 +1,26 @@
1
1
  # reladraw
2
2
 
3
- A text language for diagrams where **you say where things go**.
3
+ reladraw is a text language for diagrams where you say where things go.
4
4
 
5
- **[Try it in your browser →](https://reladraw.github.io/reladraw/)** — edit the source on the left, watch the layout re-solve on the right. Nothing to install.
5
+ **[Try it in your browser →](https://reladraw.github.io/reladraw/)**
6
6
 
7
- A diagram drawn by hand in draw.io:
7
+ I wanted to be able to create custom, expressive diagrams where I decided how to arrange the diagram, but without the inefficiency of manually drawing in draw.io. For example, this is a diagram drawn in draw.io:
8
8
 
9
- ![The reference diagram, drawn by hand](https://raw.githubusercontent.com/reladraw/reladraw/main/examples/reference/arch.png)
9
+ ![Hand-drawn reference diagram](https://raw.githubusercontent.com/reladraw/reladraw/main/examples/reference/arch.png)
10
10
 
11
- And the same diagram written down in reladraw and rendered from the text — [`examples/arch.reladraw`](examples/arch.reladraw), 44 statements, no coordinates anywhere in it:
11
+ This is the same diagram, but written in reladraw ([`examples/arch.reladraw`](examples/arch.reladraw)).
12
12
 
13
- ![The same diagram, rendered from reladraw source](https://raw.githubusercontent.com/reladraw/reladraw/main/docs/arch-render.png)
13
+ ![reference diagram via reladraw](https://raw.githubusercontent.com/reladraw/reladraw/main/docs/arch-render.png)
14
14
 
15
- Every distance in the second picture was worked out from statements like `above-left of cluster.hub` and `between cluster.desktop1 and cluster.laptop1`. Nothing chose the arrangement; the file states it.
15
+ ## Why not Mermaid or draw.io?
16
16
 
17
- ## The gap
17
+ Mermaid, Graphviz and D2 let you declare boxes and connections, then determine positions for you. If you have a particular picture in mind, these aren't the right tool.
18
18
 
19
- Mermaid, Graphviz and D2 have you declare entities and connections, and then place everything for you. That is a superpower, and for most diagrams it is the right one. It stops being the right one as soon as you have a particular picture in mind and care where things go. Say you are actively building your understanding of a system by diagramming it, and you want some module over to the right with its connections placed just so: the auto-layout languages have no way to say it.
20
-
21
- On the other end of the spectrum are the absolute-positioning tools — draw.io, Excalidraw, Figma. They give you total control of placement, at the cost of making every edit to a complex diagram slow hand-work. And it is slow for a human but expensive for an agent, which has to work the picture out from the coordinates before it can decide which ones to change.
19
+ On the other hand, tools like draw.io or Excalidraw allow absolute placement, but that means much more effort, whether for humans clicking and dragging nodes around or agents recalculating coordinates and editing verbose XML source code files.
22
20
 
23
21
  ![The two ends of the spectrum, with reladraw between them](https://raw.githubusercontent.com/reladraw/reladraw/main/docs/gap.png)
24
22
 
25
- reladraw aims at the middle. Every position is stated relative to something else, and nothing in the file is a coordinate:
23
+ reladraw sits between those two extremes, aiming to have the benefits of a diagram language, like Mermaid, but also having the expressiveness and custom placement that you can get with draw.io. Positions are relative, so you don't have to manually pick coordinates. For example:
26
24
 
27
25
  ```
28
26
  node app "Web app"
@@ -34,41 +32,9 @@ node store "Database" right of app level with app
34
32
  edge app.api -> store "queries" from: right to: left
35
33
  ```
36
34
 
37
- Nothing is nested, so no line depends on another line's position or indentation.
38
-
39
- The draft is in [SYNTAX.md](SYNTAX.md), with a worked example in [examples/](examples/).
40
-
41
- ## Why an agent needs this
42
-
43
- The common case is not drawing a diagram, it is changing one. Ask for the auth service to move left and a queue to go behind it. With pixel coordinates, an agent has to rebuild the picture from the numbers before it can work out which numbers to change. With auto-layout there is nothing to read at all, because the arrangement was never written down — it can only reword the source and re-render. With stated placement the arrangement is in the file as sentences, and changing the picture is changing the sentence that says where the thing goes.
44
-
45
- Writing has the same shape. An agent emitting Mermaid is guessing at a layout that an algorithm settles later, and its only way to find out is to render and look — a round trip that comes back as a picture rather than as a list of what is wrong.
46
-
47
- Intent is confirmable, outcomes are not, and the difference is worth being precise about. An agent can re-read its own file and see that the database is under the API and all four machines hang off the sync hub. It cannot see that two clusters anchored to different things now overlap, that a text overflowed its node, or that an edge crosses four others — those are resolved from the statements rather than stated, so they need the diagnostics in the scope section below.
48
-
49
- ### Using it with an agent
50
-
51
- reladraw is too new to be in any model's training data, so an agent has to be told the language before it can write it. This repository ships an [agent skill](https://agentskills.io) that does exactly that — the syntax, when to reach for the language, and what re-reading its own source can and cannot confirm.
52
-
53
- ```
54
- npx skills add reladraw/reladraw -g
55
- ```
56
-
57
- That installs it for whichever agent you use — Claude Code, Codex, Cursor, Copilot and others — each into its own skills directory. Drop the `-g` to install it into the current project instead.
58
-
59
- To install it for one agent rather than all of them, name it with `-a`:
60
-
61
- ```
62
- npx skills add reladraw/reladraw -g -a claude-code
63
- ```
64
-
65
- Re-run whichever command you used after a release that changes the syntax. The skill is a copy taken at install time, not a live link, so nothing refreshes it on its own.
66
-
67
- It is [plain Markdown](.claude/skills/reladraw/SKILL.md) with the syntax reference beside it, so it is worth reading whatever you use, and copying the directory by hand works just as well.
68
-
69
- ## Status
35
+ See also [SYNTAX.md](SYNTAX.md) and [examples/](examples/).
70
36
 
71
- Version 0.7.1. Early, but it runs: a parser, resolver and SVG renderer in TypeScript with no runtime dependencies, and a command-line tool that takes a text file and writes a standalone SVG. The comparison at the top of this page is that pipeline run on [`examples/arch.reladraw`](examples/arch.reladraw). What is still visibly off there is typography, not placement.
37
+ ## Install
72
38
 
73
39
  ```
74
40
  npm install -g reladraw
@@ -82,65 +48,32 @@ npm install && npm run build
82
48
  node dist/cli.js examples/arch.reladraw -o out.svg
83
49
  ```
84
50
 
85
- Not built yet, roughly in the order they are missed:
51
+ ## Using it with an agent
86
52
 
87
- - **The diagnostics report.** The scope section below says what it is for. Today the tool either renders or fails; it will not tell you what is wrong with a picture it drew successfully.
88
- - **Edge routing around nodes.** An edge can be told which side of a node to leave and arrive on, and which gap to run down on the way. An edge that says none of that is a straight line between two centers, and it will cut through whatever stands in the way.
89
- - **More pictures.** Icons and shapes are closed sets drawn from path data inside the tool, so a diagram wanting one that is not there has nowhere to go.
53
+ To install a skill to let your agent know how to use reladraw:
90
54
 
91
- The language is not stable. Expect the syntax to change.
92
-
93
- ## How it works
94
-
95
- A gap is a *minimum* distance, never an exact one. Say two things sit side by side, then say a third goes between them, and the first two are pushed apart by exactly what the third needs; delete the third and they close back up. That is the step an author otherwise does by hand — shove things apart to make room, then drag everything back so the diagram is not full of holes — and no number goes stale when a text grows.
96
-
97
- So the resolver solves a system rather than walking a chain. Each axis is a set of minimum distances, and the tightest arrangement satisfying all of them is found by longest paths: one answer, no search, no arrangement ever tried and rejected. The engine works out distances; which side of what a thing sits on came from the file.
98
-
99
- That is also what makes non-overlap affordable, so it holds for every pair of nodes without anyone writing it down. On its own "these two must not overlap" is a choice among four directions, which is the search this design refuses — but the file has usually settled it already: if your arrangement lets one node travel away from another and offers no way back, that is the only separation it permits. Where the file orders a pair on neither axis, the tool names them rather than guessing; where it orders them on both, the tie breaks toward the axis of least overlap, which is the smallest movement and the one place the tool decides something nobody wrote.
100
-
101
- Nothing is nudged. Each round derives the separations the file already implied, adds them as ordinary minimum distances, and solves the whole thing again from scratch — repairing a solved layout in place is the thing being avoided.
102
-
103
- ## Scope for a first version
104
-
105
- - Parser *(done)*
106
- - Deterministic resolver: minimum distances in, tightest arrangement out *(done)*
107
- - Static SVG renderer *(done)*
108
- - A command-line tool: text file in, SVG out *(done)*
109
- - A placement grammar that can say what a real diagram needs: several placements on one node, one thing between two others, exact side-to-side alignment *(done)*
110
- - Nodes that do not overlap by default, with the separation direction derived from the stated arrangement *(done)*
111
- - Minimal node-avoiding edge routing
112
- - Machine-readable diagnostics from the solved geometry
113
-
114
- Diagnostics are a real output rather than a debugging aid. What they cannot do is stand in for the grammar: a check catches only what the language genuinely leaves open, and "these must not overlap" rules arrangements out without naming one, so it can never place anything. Everything the source cannot tell you is computable once the geometry is solved, with no image involved: overlapping nodes, crossed edges, text exceeding its container, anything off-canvas, large dead regions. So the tool reports `hub overlaps laptop1` and `edge auth->db crosses 4 edges`, and the fix is written in the same vocabulary as the source. An agent working this way reads a report about a text file it wrote and edits that text file — no rendering, no vision model, no pixel arithmetic.
115
-
116
- A diagnostic never repairs a solved layout in place. That is the line the design holds: a checker allowed to nudge nodes is a layout algorithm with a bad search strategy, fixing one overlap into the next with no view of the whole. Deriving a constraint the file already implied and solving the whole system again is a different thing, and is how non-overlap works. What is left over — anything the source genuinely does not settle — is reported, naming the statement that was broken, and the author edits the source. Open, and it decides how far this goes: may a diagnostic describe a fix in words, or only name the symptom? Describing one means the tool has an opinion about layout, which is the auto-layout instinct coming back in through the side door.
117
-
118
- Three design problems decide how much machinery this needs, and the first outranks the other two:
119
-
120
- **Saying enough.** The benchmark contains arrangements the grammar cannot express at all, which is why some nodes land in the wrong place no matter how the file is written. So the work is adding statements, not restricting them. Expressiveness is not the danger; the engine *choosing* an arrangement is.
121
-
122
- **What the engine is allowed to decide.** Auto-layout is refused, because a picture chosen by an algorithm is not predictable from its source, and that predictability is the entire point. Working out coordinates from an arrangement the author stated is a different thing and is simply the job. The test between them: the engine's freedom may affect distances and never relationships. If a default can change which side of something a node sits on, the language was short a statement and the tool should say so rather than guess.
123
-
124
- **Overlap and edge routing.** Relative placement with default spacing collides as soon as two clusters grow toward each other, and nodes are kept apart for exactly that reason. Edges are different: which side of a node a line passes is a relationship, so the tool does not pick a route around an obstacle by itself. You say where a line goes instead — the side it leaves and arrives on, the gap it runs through, the nodes it passes above or below — and the line is drawn curved, square or straight through those points. A line that crosses something it should not is one you have not said enough about yet. Whether the tool should at least point that out, naming the clause that would fix it, is an open question.
55
+ ```
56
+ npx skills add reladraw/reladraw -g
57
+ ```
125
58
 
126
- ## Prior art
59
+ That installs it for every agent you use (Claude Code, Codex, Cursor, Copilot and others), each in its own skills directory. Leave off `-g` to install it into the current project only. To install it for just one agent, name it with `-a`:
127
60
 
128
- Four things sit near this, and each answers a different part of the problem.
61
+ ```
62
+ npx skills add reladraw/reladraw -g -a claude-code
63
+ ```
129
64
 
130
- **[Archify](https://github.com/tt-a1i/archify).** Built for agents to write: an agent emits typed JSON, a validator checks it against a schema and lints the layout, and it compiles deterministically to a good-looking, self-contained HTML file, with stepped playback and nodes pinned to git-verified source lines. Its positioning is grid or free coordinates, though — auto-arrangement on one side, absolute pixels on the other — and a JSON template says nothing about where the picture will end up, so the loop is still emit, render, look, tweak.
65
+ That gets you a copy of the skill at the time you run it, so you'll need to re-run that command to get the latest skill when there is a new release.
131
66
 
132
- **PIC and [pikchr](https://pikchr.org).** A text diagram format with no layout engine, where placement is stated and deterministic. But it is turtle graphics — a movable cursor that drops shapes and steps along — so a diagram is a sequence of pen movements rather than a set of stated relationships between named things. There is no group that reflows when a member is added.
67
+ ## Status
133
68
 
134
- **Graphviz `rank` and `cluster`.** Constraints on an auto-layout engine rather than a replacement for one, so output stays emergent and unpredictable from the source.
69
+ Version 0.8.1. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG.
135
70
 
136
- **Structurizr.** Has real manual layout, but is bound to the C4 model, which makes it a modeling notation with a renderer attached rather than a general placement language.
71
+ The language isn't stable yet, so expect the syntax to change.
137
72
 
138
73
  ## License
139
74
 
140
- Apache-2.0. This is a reusable primitive where adoption is the value, so restricting commercial use would defeat the purpose. See [LICENSE](LICENSE).
141
-
142
- The license covers the code, not the name: it grants no rights to use "reladraw", the project logo, or the project's other marks. Forks are welcome and should carry a different name. See [NOTICE](NOTICE).
75
+ Apache-2.0. See [LICENSE](LICENSE). The license covers the code, not the name. It grants no rights to "reladraw", the project logo or the project's other marks. See [NOTICE](NOTICE).
143
76
 
144
77
  ## Contributing
145
78
 
146
- Issues are wanted — especially a diagram you tried to write and could not. While the language is changing quickly, an issue is more useful than a pull request; [CONTRIBUTING.md](CONTRIBUTING.md) says why.
79
+ Issues are welcome. Particularly helpful is a diagram you could not represent in reladraw. While the language is still changing quickly, an issue helps more than a pull request. See [CONTRIBUTING.md](CONTRIBUTING.md).
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.7.1
1
+ # Syntax reference — 0.8.1
2
2
 
3
3
  What the language accepts. The parser, resolver and SVG renderer implement all of it; the sections at the end record what is defective, unchecked or undecided.
4
4
 
@@ -171,6 +171,7 @@ Every node has a *body*: the thing that is drawn where the node is. Two keys nam
171
171
  | --- | --- |
172
172
  | `rectangle` | The plain rounded box. The default, so nothing has to say it. |
173
173
  | `document` | The same box with its top-right corner folded — the flowchart symbol saying *this is an artifact, not a process*. |
174
+ | `circle` | A circle just big enough to hold the text, and always round — a longer text makes a bigger circle, never an oval. |
174
175
  | `none` | No outline, no fill, no padding. The node is its text and nothing else. |
175
176
 
176
177
  `icon: <name>` is a picture the node is drawn **as**, with no box at all.
@@ -193,7 +194,11 @@ node svc icon: cube
193
194
 
194
195
  A node has one body, so writing both keys is an error naming both.
195
196
 
196
- The `document` fold is worth having because a shape is a second channel alongside color, and a stronger one. A fill means whatever you assigned it and a reader has to learn it from the diagram; a folded corner has meant "a document" for as long as there have been flowcharts, and reads with no legend. Most diagrams lose the difference between a thing that runs and a thing that is produced, because every node is a rectangle. `circle` and `diamond` will join these when a diagram asks for them.
197
+ The `document` fold is worth having because a shape is a second channel alongside color, and a stronger one. A fill means whatever you assigned it and a reader has to learn it from the diagram; a folded corner has meant "a document" for as long as there have been flowcharts, and reads with no legend. Most diagrams lose the difference between a thing that runs and a thing that is produced, because every node is a rectangle. `diamond` will join these when a diagram asks for one.
198
+
199
+ A circle is an ordinary node in every other way, and the file means the same thing whichever shape its nodes are. `from: right` on a circle leaves from its rightmost point, and several edges on one side spread round the quarter of the circle about that point, each meeting it square on. A corner such as `top-right` is the point on the circle halfway between those two sides, not the corner of the square around it. A circle can hold other nodes; they sit in the middle of it. A circle cannot yet take a `badge:`, a `deck:`, or a node placed against its own edge or text, and each is refused with an error saying so.
200
+
201
+ `circle` is the one name that describes the picture rather than the meaning, because a circle has no single meaning to name it after: it is a state in one diagram, a step in another, a person in a third.
197
202
 
198
203
  A name says what the thing *is*, never what the picture looks like: naming the meaning is what lets the drawing be improved later without every diagram that uses it changing sense. `document`, not `folded-corner`. The one place that rule stops is a picture with no single meaning — the cube stands for a container in one diagram, a VM in another, a service in a third — which is why it is called `cube` and not `instance`. An unrecognized name is an error listing the whole set, rather than a node that quietly draws nothing.
199
204
 
@@ -437,6 +442,10 @@ Several such lines over the same row take a lane each, the shorter inside the lo
437
442
 
438
443
  Sides at right angles can face away too. `from: left to: top` with the far node to the right leaves heading away from it, so the line steps out of the left, goes up past both nodes, runs along above them and comes straight down into the top. The side named at the far end says which way round: `to: bottom` takes it underneath instead.
439
444
 
445
+ When the two nodes are stacked instead — `from: bottom to: left` into a node directly below — going out past both and coming straight in would pass through the lower node. There the line is one curve, bending round the corner into the side named. If even the curve would cross a node, the line goes round the node on the side it names, as `left of` that node would send it.
446
+
447
+ Both ends can name the same side. `from: left to: left` with the far node to the right leaves heading away from it, and has to get round the node behind to reach the far side. It goes over the top, as the lines above do, and down into the gap; in a column, `from: bottom to: bottom` goes round the right.
448
+
440
449
  Over the top is the rule, not a guess the tool makes each time — it does not look for the shorter way round. To send the line underneath or round the left, say which side of a node it passes: `below resolver`. See "Passing on one side of a node".
441
450
 
442
451
  #### Several edges between the same two sides
@@ -1012,6 +1021,16 @@ That one was found by testing the lexer, not by rendering — and it could not h
1012
1021
 
1013
1022
  Pre-1.0, so the minor number is where a breaking change goes. Every removal below is refused by name with the replacement quoted, rather than dropped in silence — an older file stops with an error saying what to write instead.
1014
1023
 
1024
+ **0.8.1**
1025
+
1026
+ - A fix, no syntax change. An edge naming a side at both ends could be drawn through one of its own nodes in two cases. With the nodes stacked and the sides at right angles, such as `from: bottom to: left`, the line went down past the lower node and came back up through it; it is now one curve round the corner. With both ends naming the same side, such as `from: left to: left`, the line ran straight through the node behind; it now goes over the top of that node, or round the right in a column. Existing diagrams with such an edge will look different, and correct.
1027
+
1028
+ - A fix, no syntax change. Children of a container stack close together, and an edge with no text between two of them used to get only that step, so it drew as an arrowhead with no line behind it. The gap now widens to show a short run of line behind each head, as an edge with text already did for its text. Existing diagrams with such an edge will look different, and correct.
1029
+
1030
+ **0.8.0**
1031
+
1032
+ - `shape: circle`. A circle is sized to hold its text and is always round. Edges on one of its sides spread round that quarter of the circle, and a corner of it is the point on the circle halfway round. It does not yet take a badge, a deck or a node placed against its own edge. Nothing that already renders changes.
1033
+
1015
1034
  **0.7.1**
1016
1035
 
1017
1036
  - A fix, no syntax change. An edge with `line: (path: straight)` and a clause such as `below m` could draw its text on top of `m`. The line now bends through the room left for the text, so the text sits clear of the node. Existing diagrams with such an edge will look different, and correct.
@@ -59,6 +59,14 @@ export declare const THICKNESS: Record<string, number>;
59
59
  * thickness is a size, like a text's, and a size is allowed to take room.
60
60
  */
61
61
  export declare function arrowLength(thickness: number): number;
62
+ /**
63
+ * Line left showing behind the head of an edge with no text, so a gap an arrow
64
+ * crosses is at least this plus its heads. Stacked children sit `CHILD_GAP`
65
+ * apart, which is less than one head, and an arrow between them drew as a head
66
+ * with no line. This is what the tightest named gap already leaves behind a
67
+ * normal head, so any gap an author could have asked for holds it unchanged.
68
+ */
69
+ export declare const BARE_EDGE_RUN: number;
62
70
  /**
63
71
  * Line left showing between an edge's text and the box at that end of the
64
72
  * corridor it crosses.
package/dist/constants.js CHANGED
@@ -71,6 +71,14 @@ export const THICKNESS = {
71
71
  export function arrowLength(thickness) {
72
72
  return ARROW_MARKER_WIDTH * thickness;
73
73
  }
74
+ /**
75
+ * Line left showing behind the head of an edge with no text, so a gap an arrow
76
+ * crosses is at least this plus its heads. Stacked children sit `CHILD_GAP`
77
+ * apart, which is less than one head, and an arrow between them drew as a head
78
+ * with no line. This is what the tightest named gap already leaves behind a
79
+ * normal head, so any gap an author could have asked for holds it unchanged.
80
+ */
81
+ export const BARE_EDGE_RUN = GAPS['tight'] - ARROW_LENGTH;
74
82
  /**
75
83
  * Line left showing between an edge's text and the box at that end of the
76
84
  * corridor it crosses.
package/dist/icons.d.ts CHANGED
@@ -50,13 +50,16 @@ export declare const ICON_NAMES: string[];
50
50
  * diagram, while a folded corner has meant "an artifact, not a process" in
51
51
  * flowcharts for decades and reads with no legend at all.
52
52
  *
53
- * `circle` and `diamond` join these when a diagram asks. The set being short is
54
- * a fact about what has been drawn, not about the key.
53
+ * `circle` is the one geometric name, because a circle carries no single
54
+ * meaning to name it after — it is a state, a step, a person, whatever the
55
+ * diagram's own convention makes it. `diamond` joins these when a diagram
56
+ * asks. The set being short is a fact about what has been drawn, not about the
57
+ * key.
55
58
  */
56
- export declare const OUTLINES: readonly ["rectangle", "document"];
59
+ export declare const OUTLINES: readonly ["rectangle", "document", "circle"];
57
60
  export type Outline = (typeof OUTLINES)[number];
58
61
  /** Every value `shape:` accepts, `none` included. */
59
- export declare const SHAPE_WORDS: readonly ["rectangle", "document", "none"];
62
+ export declare const SHAPE_WORDS: readonly ["rectangle", "document", "circle", "none"];
60
63
  /**
61
64
  * What a node is drawn as: an outline, a picture, or nothing at all.
62
65
  *
package/dist/icons.js CHANGED
@@ -126,10 +126,13 @@ export const ICON_NAMES = Object.keys(ICONS);
126
126
  * diagram, while a folded corner has meant "an artifact, not a process" in
127
127
  * flowcharts for decades and reads with no legend at all.
128
128
  *
129
- * `circle` and `diamond` join these when a diagram asks. The set being short is
130
- * a fact about what has been drawn, not about the key.
129
+ * `circle` is the one geometric name, because a circle carries no single
130
+ * meaning to name it after — it is a state, a step, a person, whatever the
131
+ * diagram's own convention makes it. `diamond` joins these when a diagram
132
+ * asks. The set being short is a fact about what has been drawn, not about the
133
+ * key.
131
134
  */
132
- export const OUTLINES = ['rectangle', 'document'];
135
+ export const OUTLINES = ['rectangle', 'document', 'circle'];
133
136
  /** Every value `shape:` accepts, `none` included. */
134
137
  export const SHAPE_WORDS = [...OUTLINES, 'none'];
135
138
  const PLAIN = { kind: 'shape', outline: 'rectangle' };
package/dist/render.js CHANGED
@@ -230,6 +230,16 @@ const FOLD = CORNER * 2;
230
230
  /** The node's outline, as path data. */
231
231
  function outlinePath(shape, x, y, w, h) {
232
232
  const r = CORNER;
233
+ if (shape === 'circle') {
234
+ // Two half-turns from the leftmost point, since one arc cannot close.
235
+ const radius = w / 2;
236
+ return [
237
+ `M${round(x)} ${round(y + h / 2)}`,
238
+ `a${round(radius)} ${round(radius)} 0 1 0 ${round(w)} 0`,
239
+ `a${round(radius)} ${round(radius)} 0 1 0 ${round(-w)} 0`,
240
+ 'Z',
241
+ ].join(' ');
242
+ }
233
243
  if (shape === 'document') {
234
244
  // Every corner rounded but the top-right one, which is cut away and folded.
235
245
  return [
@@ -897,6 +907,10 @@ function sidePoint(box, toward) {
897
907
  const dy = toward.y - center.y;
898
908
  if (dx === 0 && dy === 0)
899
909
  return center;
910
+ if (box.round) {
911
+ const scale = box.width / 2 / Math.hypot(dx, dy);
912
+ return { x: center.x + dx * scale, y: center.y + dy * scale };
913
+ }
900
914
  const scaleX = dx === 0 ? Infinity : box.width / 2 / Math.abs(dx);
901
915
  const scaleY = dy === 0 ? Infinity : box.height / 2 / Math.abs(dy);
902
916
  const scale = Math.min(scaleX, scaleY);
@@ -1291,6 +1305,8 @@ function claim(claims, node, side, entry) {
1291
1305
  }
1292
1306
  /** The point `at` along one side of a box, with the outward normal for that side. */
1293
1307
  function anchorOn(face, side, at) {
1308
+ if (face.round)
1309
+ return anchorOnCircle(face, side, at);
1294
1310
  switch (side) {
1295
1311
  case 'top':
1296
1312
  return { x: at, y: face.y, tx: 0, ty: -1, side };
@@ -1302,6 +1318,28 @@ function anchorOn(face, side, at) {
1302
1318
  return { x: face.x + face.width, y: at, tx: 1, ty: 0, side };
1303
1319
  }
1304
1320
  }
1321
+ /**
1322
+ * The same, on a circle: a side is the quarter of the circle around its
1323
+ * compass point, and `at` is walked round the arc rather than along a
1324
+ * straight edge, so points spaced a step apart on a side are a step apart on
1325
+ * the circle too. The line meets the circle square on, heading from its center.
1326
+ */
1327
+ function anchorOnCircle(face, side, at) {
1328
+ const radius = face.width / 2;
1329
+ const center = centerOf(face);
1330
+ const across = side === 'top' || side === 'bottom' ? at - center.x : at - center.y;
1331
+ const turn = Math.max(-Math.PI / 4, Math.min(Math.PI / 4, across / radius));
1332
+ // SVG's y runs down, so the bottom is a quarter-turn clockwise from the right.
1333
+ const angle = {
1334
+ right: turn,
1335
+ bottom: Math.PI / 2 - turn,
1336
+ left: Math.PI - turn,
1337
+ top: -Math.PI / 2 + turn,
1338
+ }[side];
1339
+ const tx = Math.cos(angle);
1340
+ const ty = Math.sin(angle);
1341
+ return { x: center.x + radius * tx, y: center.y + radius * ty, tx, ty, side };
1342
+ }
1305
1343
  /** An end with no side named: leave from the border, pointing at the far end. */
1306
1344
  function free(face, toward) {
1307
1345
  const point = sidePoint(face, toward);
@@ -1324,6 +1362,20 @@ function exitAlong(box, from, dir) {
1324
1362
  // is the graceful answer, and the crowding it signals is a diagnostic.
1325
1363
  const x = Math.min(Math.max(from.x, box.x), box.x + box.width);
1326
1364
  const y = Math.min(Math.max(from.y, box.y), box.y + box.height);
1365
+ if (box.round) {
1366
+ // Where the ray leaves the circle: the larger root of |p + t·dir − c| = r.
1367
+ const center = centerOf(box);
1368
+ const px = x - center.x;
1369
+ const py = y - center.y;
1370
+ const a = dir.x * dir.x + dir.y * dir.y;
1371
+ const b = px * dir.x + py * dir.y;
1372
+ const c = px * px + py * py - (box.width / 2) ** 2;
1373
+ const reach = b * b - a * c;
1374
+ if (a === 0 || reach < 0)
1375
+ return { x, y };
1376
+ const t = Math.max(0, (-b + Math.sqrt(reach)) / a);
1377
+ return { x: x + dir.x * t, y: y + dir.y * t };
1378
+ }
1327
1379
  const tx = dir.x === 0 ? Infinity : ((dir.x > 0 ? box.x + box.width : box.x) - x) / dir.x;
1328
1380
  const ty = dir.y === 0 ? Infinity : ((dir.y > 0 ? box.y + box.height : box.y) - y) / dir.y;
1329
1381
  const t = Math.min(tx, ty);
@@ -1508,14 +1560,37 @@ function planLoops(edges, nodes, ends, corridors, routes, measurer, fontSize) {
1508
1560
  const { start, end } = ends.get(edge);
1509
1561
  if (start.side === undefined || end.side === undefined)
1510
1562
  continue;
1511
- // Sides at right angles, one facing away from the other end.
1563
+ // Sides at right angles, one facing away from the other end. Going out
1564
+ // past both boxes and coming straight in is right when the boxes are side
1565
+ // by side. When the other end lies within the span of the box facing away
1566
+ // — stacked boxes — coming straight in would pass through that box, so
1567
+ // the curve is kept, which has room to bend round the corner; and if even
1568
+ // the curve hits a box, the line goes round the facing-away box on the
1569
+ // side it names.
1512
1570
  if (start.tx * end.tx + start.ty * end.ty === 0) {
1571
+ const away = awayEnd(edge, start, end);
1572
+ if (away && within(faceOf(away.node), away.anchor, away.other)) {
1573
+ if (curveHits(edge, ends.get(edge), nodes)) {
1574
+ implyRoute(edge, away.node, sidePass(away.anchor.side), nodes, ends, routes, measurer, fontSize);
1575
+ }
1576
+ continue;
1577
+ }
1513
1578
  const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
1514
1579
  const plan = turnBack(edge, start, end, inWay, routes, measurer, fontSize);
1515
1580
  if (plan)
1516
1581
  tops.push(plan);
1517
1582
  continue;
1518
1583
  }
1584
+ // Both sides facing the same way, one box behind the other: the line has
1585
+ // to get round that box to reach the far side, which a curve cannot. It
1586
+ // goes over the top, or round the right for a column, as a loop does.
1587
+ if (start.tx === end.tx && start.ty === end.ty) {
1588
+ const away = awayEnd(edge, start, end);
1589
+ if (away && curveHits(edge, ends.get(edge), nodes)) {
1590
+ implyRoute(edge, away.node, start.tx !== 0 ? 'above' : 'right', nodes, ends, routes, measurer, fontSize);
1591
+ }
1592
+ continue;
1593
+ }
1519
1594
  // The two ends point opposite ways along one axis, each away from the other.
1520
1595
  if (start.tx !== -end.tx || start.ty !== -end.ty)
1521
1596
  continue;
@@ -1703,6 +1778,90 @@ function turnBack(edge, start, end, inWay, routes, measurer, fontSize) {
1703
1778
  },
1704
1779
  };
1705
1780
  }
1781
+ /**
1782
+ * Whether the single curve an edge would otherwise be drawn as passes through
1783
+ * a box: either of its own, or any other that holds neither end. Measured on
1784
+ * the curve itself, so the answer is the picture's and not a rule about which
1785
+ * sides were named.
1786
+ *
1787
+ * Only passing through counts. Also counting a curve that merely comes close,
1788
+ * or whose text touches a node, was tried (2026-09-27) and sent nearly every
1789
+ * such edge the long way round with square corners, which the user rejected
1790
+ * outright.
1791
+ */
1792
+ function curveHits(edge, { start, end, bow }, nodes) {
1793
+ const reach = controlReach(start, end);
1794
+ const lift = 4 / 3;
1795
+ const bx = (bow?.x ?? 0) * lift;
1796
+ const by = (bow?.y ?? 0) * lift;
1797
+ const c1 = { x: start.x + start.tx * reach + bx, y: start.y + start.ty * reach + by };
1798
+ const c2 = { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by };
1799
+ const at = (t) => {
1800
+ const u = 1 - t;
1801
+ return {
1802
+ x: u * u * u * start.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * end.x,
1803
+ y: u * u * u * start.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * end.y,
1804
+ };
1805
+ };
1806
+ // The line starts and ends on its own boxes' sides, so those are shrunk a
1807
+ // pixel to let it touch them there.
1808
+ const boxes = [
1809
+ ...[edge.from, edge.to].map((node) => grow(extentOfBox(faceOf(node)), -1)),
1810
+ ...nodes
1811
+ .filter((node) => !contains(node, edge.from) && !contains(node, edge.to))
1812
+ .map((node) => extentOfBox(faceOf(node))),
1813
+ ];
1814
+ const steps = 32;
1815
+ let previous = start;
1816
+ for (let step = 1; step <= steps; step += 1) {
1817
+ const point = at(step / steps);
1818
+ if (boxes.some((box) => segmentHits(previous, point, box)))
1819
+ return true;
1820
+ previous = point;
1821
+ }
1822
+ return false;
1823
+ }
1824
+ /**
1825
+ * The end of an edge whose side faces away from the other end, with its node
1826
+ * and the other end's anchor; the start if both do.
1827
+ */
1828
+ function awayEnd(edge, start, end) {
1829
+ const facesAway = (from, to) => from.tx * (to.x - from.x) + from.ty * (to.y - from.y) < 0;
1830
+ if (facesAway(start, end))
1831
+ return { node: edge.from, anchor: start, other: end };
1832
+ if (facesAway(end, start))
1833
+ return { node: edge.to, anchor: end, other: start };
1834
+ return undefined;
1835
+ }
1836
+ /**
1837
+ * Whether a line coming straight in to `other`, across the way `anchor`'s side
1838
+ * faces, would pass through `box` — that is, `other` lies within the box's
1839
+ * span along that way.
1840
+ */
1841
+ function within(box, anchor, other) {
1842
+ const run = anchor.tx !== 0 ? 'x' : 'y';
1843
+ return lo(box, run) < other[run] && other[run] < hi(box, run);
1844
+ }
1845
+ /** The clause that passes a node on the side named. */
1846
+ function sidePass(side) {
1847
+ return side === 'top' ? 'above' : side === 'bottom' ? 'below' : side;
1848
+ }
1849
+ /**
1850
+ * Route an edge as if it carried one clause the author did not write, passing
1851
+ * `node` on that side. If that cannot be drawn, the edge keeps its curve: a
1852
+ * refusal would name a clause that is not in the file, and would refuse a file
1853
+ * earlier versions drew.
1854
+ */
1855
+ function implyRoute(edge, node, direction, nodes, ends, routes, measurer, fontSize) {
1856
+ const written = `${direction === 'left' || direction === 'right' ? `${direction} of` : direction} ${node.name}`;
1857
+ try {
1858
+ routes.set(edge, planRoute(edge, [{ direction, nodes: [node], written }], nodes, ends.get(edge), measurer, fontSize));
1859
+ }
1860
+ catch (error) {
1861
+ if (!(error instanceof SourceError))
1862
+ throw error;
1863
+ }
1864
+ }
1706
1865
  /** The most a route's corner is rounded by. */
1707
1866
  const ROUTE_RADIUS = 20;
1708
1867
  /**
@@ -2421,6 +2580,7 @@ function faceOf(node) {
2421
2580
  y: node.y + node.inset,
2422
2581
  width: node.width - node.inset,
2423
2582
  height: node.height - node.inset,
2583
+ round: node.body.kind === 'shape' && node.body.outline === 'circle',
2424
2584
  };
2425
2585
  }
2426
2586
  function centerOf(box) {
package/dist/resolve.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ALL_ATTR_KEYS, ATTR_KEYS, PART_SIDES, COLOR_KEYS, COLOR_PARTS, CONTENT_ALIGNMENTS, CONTENT_WIDTHS, LINE_VALUES, describePlacement, nameTarget, } from './ast.js';
2
- import { CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, TEXT_CLEARANCE, PAD, SEPARATION_GAP, THICKNESS, arrowLength, fontSizeFor, leafTop, textExtent, textStyleFor, widestLine, } from './constants.js';
2
+ import { BARE_EDGE_RUN, CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, TEXT_CLEARANCE, PAD, SEPARATION_GAP, THICKNESS, arrowLength, fontSizeFor, leafTop, textExtent, textStyleFor, widestLine, } from './constants.js';
3
3
  import { fix, reachability, tightest } from './constrain.js';
4
4
  import { SourceError } from './errors.js';
5
5
  import { bodyFor } from './icons.js';
@@ -598,6 +598,9 @@ function sizeNode(node, edges, measurer, fontSize, local) {
598
598
  // too. The *absence* of children makes the setting inert, which stays silent;
599
599
  // a value the language does not have is wrong wherever it is written.
600
600
  const contents = contentsStyleFor(node);
601
+ const circle = isCircle(node);
602
+ if (circle)
603
+ refuseOnCircle(node);
601
604
  if (node.children.length === 0) {
602
605
  // `at` is read on a leaf too, and is inert wherever the box is exactly the
603
606
  // size of what it holds — which is every leaf, since a leaf is sized from
@@ -629,7 +632,7 @@ function sizeNode(node, edges, measurer, fontSize, local) {
629
632
  }
630
633
  else {
631
634
  if (contents.widths === 'match')
632
- matchWidths(node.children);
635
+ matchWidths(node.children, local);
633
636
  let content = layoutChildren(node.children, edges, measurer, fontSize, local);
634
637
  if (contents.widths === 'fill') {
635
638
  // The band is the wider of the title and the contents, so filling it
@@ -638,7 +641,7 @@ function sizeNode(node, edges, measurer, fontSize, local) {
638
641
  // could not give.
639
642
  const band = Math.max(textWidth, content.width);
640
643
  for (const child of node.children)
641
- widenTo(child, band);
644
+ widenTo(child, band, local);
642
645
  content = layoutChildren(node.children, edges, measurer, fontSize, local);
643
646
  checkOneColumn(node, node.children, local);
644
647
  }
@@ -671,8 +674,62 @@ function sizeNode(node, edges, measurer, fontSize, local) {
671
674
  }
672
675
  node.banded = true;
673
676
  }
677
+ if (circle)
678
+ circumscribe(node, local);
674
679
  applyDeck(node, local);
675
680
  }
681
+ function isCircle(node) {
682
+ return node.body.kind === 'shape' && node.body.outline === 'circle';
683
+ }
684
+ /**
685
+ * Grow a node sized as a rectangle into the circle around what it holds.
686
+ *
687
+ * The circle passes a padding outside the corners of the padded-in block —
688
+ * the text of a leaf, or the band and contents of a container — so whatever
689
+ * fits in the rectangle fits in the circle, clear of its edge by the same
690
+ * padding at the corners where it comes closest. Everything inside moves to
691
+ * keep the block centered.
692
+ */
693
+ function circumscribe(node, local) {
694
+ const side = Math.hypot(node.width - PAD * 2, node.height - PAD * 2) + PAD * 2;
695
+ moveInside(node, (side - node.width) / 2, (side - node.height) / 2, local);
696
+ node.width = side;
697
+ node.height = side;
698
+ }
699
+ function moveInside(node, dx, dy, local) {
700
+ node.textBox.x += dx;
701
+ node.textBox.y += dy;
702
+ for (const child of node.children) {
703
+ const offset = local.get(child);
704
+ offset.x += dx;
705
+ offset.y += dy;
706
+ }
707
+ }
708
+ /**
709
+ * What a circle does not take yet, refused by name rather than drawn as an
710
+ * ellipse or with a copy's text outside the outline.
711
+ *
712
+ * Something placed against the circle's own edge or text — a badge included —
713
+ * sizes the frame in the same solve as the thing placed, and a circle's corner
714
+ * points are a fraction of a radius that solve does not know yet. A deck's
715
+ * copies write their text at the top-left of the face, which on a circle is
716
+ * outside it.
717
+ */
718
+ function refuseOnCircle(node) {
719
+ const [framed] = framedChildren(node);
720
+ if (node.attrs['badge'] !== undefined || node.appearance['badge'] !== undefined) {
721
+ throw new SourceError(`"${node.name}" is a circle with a badge:, and a badge is not built for circles yet — ` +
722
+ 'make it a rectangle, or place the badge as a node of its own against the circle', node.line);
723
+ }
724
+ if (framed !== undefined) {
725
+ throw new SourceError(`"${framed.name}" is placed against "${node.name}" itself, and "${node.name}" is a circle. ` +
726
+ 'Something placed against a circle\'s own edge or text is not built yet — place it against ' +
727
+ `another node, or make "${node.name}" a rectangle`, framed.line);
728
+ }
729
+ if (node.deckTexts.length > 0) {
730
+ throw new SourceError(`"${node.name}" is a circle with a deck:, and a deck is not built for circles yet`, node.line);
731
+ }
732
+ }
676
733
  /**
677
734
  * Make room for a deck's copies, once the node's own face has been sized.
678
735
  *
@@ -726,10 +783,10 @@ function contentsStyleFor(node) {
726
783
  * before layoutChildren sizes and positions anything from those widths. A
727
784
  * container's own children are already sized by this point.
728
785
  */
729
- function matchWidths(children) {
786
+ function matchWidths(children, local) {
730
787
  const maxWidth = Math.max(...children.map((child) => child.width));
731
788
  for (const child of children)
732
- widenTo(child, maxWidth);
789
+ widenTo(child, maxWidth, local);
733
790
  }
734
791
  /**
735
792
  * `widths: fill` makes every child as wide as the band, which only holds for
@@ -757,8 +814,18 @@ function checkOneColumn(node, children, local) {
757
814
  * was sized from its own contents, and the text's box was worked out against
758
815
  * the old one. A left-ranged text stays where it is; a centered or right-ranged
759
816
  * one moves by its share of the difference.
817
+ *
818
+ * A circle grows both ways, since a wider circle is a bigger one, and what is
819
+ * inside it stays centered.
760
820
  */
761
- function widenTo(node, width) {
821
+ function widenTo(node, width, local) {
822
+ if (isCircle(node)) {
823
+ const grew = width - node.width;
824
+ moveInside(node, grew / 2, grew / 2, local);
825
+ node.width = width;
826
+ node.height = width;
827
+ return;
828
+ }
762
829
  // A node with something placed against its own frame is laid out by a
763
830
  // solve, and a wider frame is one more thing that solve has to hold: the
764
831
  // things against its right edge move, and a centered text re-centers.
@@ -1013,7 +1080,7 @@ function layoutFramed(node, own, edges, measurer, fontSize, local, minWidth) {
1013
1080
  let block = { width: 0, height: 0 };
1014
1081
  if (banded) {
1015
1082
  if (contents.widths === 'match')
1016
- matchWidths(stacked);
1083
+ matchWidths(stacked, local);
1017
1084
  block = layoutChildren(stacked, edges, measurer, fontSize, local);
1018
1085
  if (contents.widths === 'fill') {
1019
1086
  // The row is the text and whatever stands beside it, each at its gap.
@@ -1025,7 +1092,7 @@ function layoutFramed(node, own, edges, measurer, fontSize, local, minWidth) {
1025
1092
  }
1026
1093
  const across = Math.max(row, block.width);
1027
1094
  for (const child of stacked)
1028
- widenTo(child, across);
1095
+ widenTo(child, across, local);
1029
1096
  block = layoutChildren(stacked, edges, measurer, fontSize, local);
1030
1097
  checkOneColumn(node, stacked, local);
1031
1098
  }
@@ -1337,6 +1404,9 @@ function memberOn(target, axis) {
1337
1404
  * away together or not at all (`left right` with `from` first); sides at right
1338
1405
  * angles each may, and either is enough (`left top` with `from` first, where
1339
1406
  * the edge turns back over the top, or with `to` below, where it goes round).
1407
+ * The same side on both is left out on purpose: that edge goes round the box
1408
+ * behind, and its text rides on the stretch over that box and the gap, so the
1409
+ * room made in the gap is what keeps a long text clear of the line's turns.
1340
1410
  */
1341
1411
  function facingAway(fromSide, toSide) {
1342
1412
  // The axis a side is on, and whether it faces away when its own end is first.
@@ -1383,22 +1453,34 @@ function liftTo(node, indexOf, local) {
1383
1453
  height: node.height,
1384
1454
  };
1385
1455
  }
1386
- /** The edges with text whose two ends are different members of this group. */
1456
+ /** The edges whose two ends are different members of this group. */
1387
1457
  function corridorsIn(edges, locate, measurer, fontSize) {
1388
1458
  const corridors = [];
1389
1459
  for (const edge of edges) {
1390
- // An edge with no text asks for nothing: every gap holds an arrowhead. And a
1391
- // edge told to pass between two named things carries its text in *that*
1460
+ // An edge told to pass between two named things carries its text in *that*
1392
1461
  // corridor rather than in the gap between its own ends, so widening this one
1393
1462
  // would make room where the text never goes.
1394
1463
  // An edge that says which side of something it passes is routed round it,
1395
1464
  // and its text rides on that route for the same reason.
1396
- if (edge.text === undefined || edge.between || edge.passes)
1465
+ if (edge.between || edge.passes)
1397
1466
  continue;
1398
1467
  const from = locate(edge.from);
1399
1468
  const to = locate(edge.to);
1400
1469
  if (!from || !to || from.index === to.index)
1401
1470
  continue;
1471
+ // With no text, the gap still has to show a run of line behind each head,
1472
+ // or two stacked children with an arrow between them draw as a head alone.
1473
+ if (edge.text === undefined) {
1474
+ const bare = BARE_EDGE_RUN + arrowLength(edge.look.thickness) * (edge.both ? 2 : 1);
1475
+ corridors.push({
1476
+ edge,
1477
+ from,
1478
+ to,
1479
+ need: { x: bare, y: bare },
1480
+ away: facingAway(edge.attrs['from'], edge.attrs['to']),
1481
+ });
1482
+ continue;
1483
+ }
1402
1484
  // The clearance is doubled because the text is drawn at the *midpoint* of
1403
1485
  // the line, so the room it needs is symmetric about that point whatever sits
1404
1486
  // at either end. The arrowhead is charged on both sides for the same reason:
@@ -1720,6 +1802,15 @@ function partOf(target, part, owner, line) {
1720
1802
  };
1721
1803
  narrow('x', 'left', 'right');
1722
1804
  narrow('y', 'top', 'bottom');
1805
+ // A circle has no corners, so a corner of one is the point on it halfway
1806
+ // between the two sides named: on the outline, as a rectangle's corner is.
1807
+ const across = words.includes('left') || words.includes('right');
1808
+ const down = words.includes('top') || words.includes('bottom');
1809
+ if (isCircle(target.node) && across && down) {
1810
+ const pull = (target.width / 2) * (1 - Math.SQRT1_2);
1811
+ box.offset.x += words.includes('left') ? pull : -pull;
1812
+ box.offset.y += words.includes('top') ? pull : -pull;
1813
+ }
1723
1814
  return box;
1724
1815
  }
1725
1816
  /** Where a node of this size sits so that the named side of it meets the span's. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,6 +34,7 @@
34
34
  "scripts": {
35
35
  "build": "tsc",
36
36
  "prepublishOnly": "npm run build",
37
+ "preversion": "./dev.sh check-version \"$npm_new_version\"",
37
38
  "dev": "tsc --watch",
38
39
  "example": "npm run build && node dist/cli.js examples/arch.reladraw -o examples/out/arch.svg"
39
40
  },