reladraw 0.5.0 → 0.7.0

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
@@ -68,7 +68,7 @@ It is [plain Markdown](.claude/skills/reladraw/SKILL.md) with the syntax referen
68
68
 
69
69
  ## Status
70
70
 
71
- Version 0.5.0. 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.
71
+ Version 0.7.0. 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.
72
72
 
73
73
  ```
74
74
  npm install -g reladraw
@@ -121,7 +121,7 @@ Three design problems decide how much machinery this needs, and the first outran
121
121
 
122
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
123
 
124
- **Overlap and edge routing.** Relative placement with default spacing collides as soon as two clusters grow toward each other. Stating placement and then routing edges afterward with no influence on them reproduces the exact failure this is meant to avoid, so minimal node-avoiding orthogonal routing belongs in the first version. Routing and diagnostics are complements, not substitutes: routing fixes what it can, and the diagnostics report what it could not.
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.
125
125
 
126
126
  ## Prior art
127
127
 
@@ -143,4 +143,4 @@ The license covers the code, not the name: it grants no rights to use "reladraw"
143
143
 
144
144
  ## Contributing
145
145
 
146
- Issues are wanted — especially a diagram you tried to write and could not. Pull requests are read but not merged yet, for a reason explained in [CONTRIBUTING.md](CONTRIBUTING.md).
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.
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.5.0
1
+ # Syntax reference — 0.7.0
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
 
@@ -422,6 +422,23 @@ You name a side and never a point on it. Alone on a side, an edge lands at its c
422
422
 
423
423
  The space it leaves is deliberately small, and shrinks further if the side is too short to hold the whole group. On a side short enough, it shrinks to nothing and the attachments coincide. Their texts still come apart, because the lines bow in the middle to make up what the side could not give them, but the arrowheads themselves land on one point and nothing warns you — so a small node with several edges arriving on one side is worth a look.
424
424
 
425
+ #### Ends that face away from each other
426
+
427
+ ```
428
+ node parser "Parser"
429
+ node resolver "Resolver" right of parser
430
+ node renderer "Renderer" right of resolver
431
+ edge parser -> renderer "test edge" from: left to: right
432
+ ```
433
+
434
+ That line leaves Parser heading left and has to arrive at Renderer from the right, so it must turn back on itself somewhere. It turns back in the gap between its two nodes when there is one wide enough to hold the line and its text, and otherwise it goes over the top: out of its side, along the top of the row clear of the tallest node between its ends, and down into the far side. The text rides on that top stretch, so it never sits on a node. The same goes for `from: top to: bottom` in a column, which goes round the right, and for an edge from a node back to itself.
435
+
436
+ Several such lines over the same row take a lane each, the shorter inside the longer, so they nest instead of drawing on top of one another.
437
+
438
+ 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
+
440
+ 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
+
425
442
  #### Several edges between the same two sides
426
443
 
427
444
  Edges that run between the *same* pair of sides are a case of their own, because "where the far ends sit" cannot order them: every one of them goes to the same node.
@@ -487,6 +504,113 @@ Several edges may share one channel, and they take a lane each. As with attachme
487
504
 
488
505
  A named channel does not widen. It is measured off the layout you described, so if you name a gap too narrow for the lines you put through it they crowd together rather than pushing the two nodes apart. That is the difference between this and a text making room for itself, above: there, the corridor is the gap between the edge's own two ends, and opening it moves them apart exactly as anything else put between them would. Here the pair is named by an edge merely passing through, and nothing yet lets an edge bid into a gap it is only a visitor in. It is the remaining half and it is not built.
489
506
 
507
+ ### Passing on one side of a node
508
+
509
+ `above`, `below`, `left of` and `right of`, written on an edge, say which side of a node the line passes.
510
+
511
+ ```
512
+ node parser "Parser"
513
+ node resolver "Resolver" right of parser
514
+ node renderer "Renderer" right of resolver
515
+ edge parser -> renderer "underneath" from: left to: right below resolver
516
+ ```
517
+
518
+ They are the placement words, and they mean what `between` means: only where the line passes that node. `below resolver` says the line is below Resolver where it goes by Resolver, not that the whole line is. That edge would go over the top of the row on its own; with the clause it leaves Parser, drops under the row and comes up into Renderer.
519
+
520
+ Write as many as you need, one for each node you have something to say about. The line meets them in the order they sit along its way, so no clause says an order:
521
+
522
+ ```
523
+ node a "A"
524
+ node b "B" below a
525
+ edge a -> b from: top to: bottom left of a right of b
526
+ ```
527
+
528
+ That line leaves the top of A, goes round A's left, crosses over in the gap between the two, passes B on the right, and turns up into B's bottom. The line is drawn as straight stretches joined by rounded corners, each stretch as close to the two ends as its clauses allow, and pushed further out, the way its clause already points, by any node sitting on it.
529
+
530
+ One clause may name several nodes, and it is not shorthand for several clauses. `below a and b` is below the box bounding both, so it covers the stretch between them too:
531
+
532
+ ```
533
+ node t1 "T1"
534
+ node t2 "T2" right of t1 (gap: wide)
535
+ node peak "Peak" right of t1 left of t2 above t1
536
+ edge t1 -> t2 from: bottom to: bottom below t1 above peak below t2
537
+ ```
538
+
539
+ Those three clauses let the line rise over Peak in the gap. Write `below t1 and t2 above peak` instead and it is refused, because Peak sits over the stretch `below t1 and t2` covers, and the line cannot be below the one and above the other at the same point.
540
+
541
+ One edge may pass some nodes above or below and others left or right. Passing above or below is done travelling across the page, and passing left or right travelling up or down it, so the line turns between the two, as few times as it can:
542
+
543
+ ```
544
+ node a "A"
545
+ node m "M" right of a
546
+ node n "N" below m right of m
547
+ node b "B" below n right of n
548
+ edge a -> b below m left of n
549
+ ```
550
+
551
+ That line leaves the bottom of A, runs across just under M, turns down on N's left, and runs across into B. The clauses read as before: `left of n` binds only where the line is level with N, so the last stretch passing under N is not held to it.
552
+
553
+ A side named at an end says which way the line goes first: `from: right` starts it across, `from: bottom` starts it down. With no side named at either end it goes across first, then down.
554
+
555
+ Clauses of one kind may sit on different legs. Here the line goes over P on the way out, down past N, and under M on the way in, turning twice:
556
+
557
+ ```
558
+ node a "A"
559
+ node p "P" right of a
560
+ node n "N" below p right of p
561
+ node m "M" below n right of n
562
+ node b "B" right of m
563
+ edge a -> b from: right to: bottom above p right of n below m
564
+ ```
565
+
566
+ Each clause binds whichever legs pass its node, so a node wide enough to sit under two legs holds both of them to its side.
567
+
568
+ Refused, each by name:
569
+
570
+ - **Clauses that cannot all hold** at one point, as above.
571
+ - **A crossing with no room.** Two clauses on opposite sides need a gap between their nodes to cross over in. Placed flush — `gap: 0` — there is none, and the error says to give the placement a gap or drop a clause. The gap is never opened for the line: you wrote how far apart those two are.
572
+ - **A node the line never passes.** `below z` on a line that never goes by Z says nothing.
573
+ - **Both kinds where no way of turning keeps them all.** The error names the clauses, or the one reason if every way fails for the same one.
574
+ - **`between` with a side.** An edge passing between two things already has a side of each.
575
+ - **A part of a node, or a gap.** A line passes a whole box, and passes it as close as reads clearly.
576
+
577
+ An end with no side named leaves from the side facing where the line is going.
578
+
579
+ ### How the line is drawn
580
+
581
+ Everything about the line itself goes in its bracket:
582
+
583
+ ```
584
+ edge api -> db line: (path: square, corners: rounded)
585
+ edge api -> cache line: (pattern: dashed, thickness: thin)
586
+ edge web -> api line: (color: #d2904e, thickness: thick, crossing: arc)
587
+ ```
588
+
589
+ `line: red` is short for `line: (color: red)`, and is still the way to write a color alone. Every other property needs the bracket, and `line: square` is refused with the bracket it meant. Like any attribute, the bracket may go on an edge, in a style, or on `default edge` — `default edge line: (path: square, corners: rounded)` draws a whole diagram with right angles.
590
+
591
+ | property | takes | says |
592
+ |---|---|---|
593
+ | `color` | a color | as `line:` alone |
594
+ | `path` | `curved` (the default), `square`, `straight` | how the line joins the points it has to meet |
595
+ | `corners` | `sharp` (the default), `rounded` | how a square or straight line turns |
596
+ | `crossing` | `none` (the default), `arc`, `gap`, `square` | how the line is drawn where it crosses an earlier one |
597
+ | `pattern` | `solid` (the default), `dashed`, `dotted`, `dash-dot` | |
598
+ | `thickness` | `thin`, `normal` (the default), `thick`, or a number of pixels | |
599
+
600
+ **A path is the rule for joining what the file fixed**: where the line leaves and which way it heads, where it arrives and from which direction, and any sides it passes. Out of A's top and into B's left, with B up and to the right:
601
+
602
+ - `curved` bends round from heading up to heading right. It is how every line was drawn before there was a choice, including the straight line an edge naming no sides has always been.
603
+ - `square` goes up, then across into B — one right angle, because leaving upward and arriving from the left fixes which way it turns. A square line naming no sides picks them: boxes sharing a column join top to bottom, boxes sharing a row side to side, and otherwise the line goes across first, then down. An end naming no side, beside one that does, takes whichever side needs the fewest turns.
604
+ - `straight` is one straight piece from A's top to B's left, ignoring which way each faces. Given `below c` or `between a and b`, it bends where that clause puts a point it has to pass, and nowhere else.
605
+
606
+ **Corners** apply wherever a line has them: every bend in a square line, and the bends a clause puts in a straight one. A curved line has none, so `corners: rounded` written on a curved edge is refused. From a style or a default it rounds the edges that have corners and leaves curved ones alone, as a style's `fill:` lands only on the things that have an inside.
607
+
608
+ **A crossing is drawn on the later line**: the edge written further down the file jumps the one written above it. `arc` is a small half-circle hop, `square` a three-sided jump, and `gap` a short break in the later line, as though it passed underneath. A line never jumps within reach of its own ends, where two lines meeting at a box are not passing each other.
609
+
610
+ **A pattern is measured in thicknesses**, so dashes keep their proportions on a thick line.
611
+
612
+ **A thick line takes room.** Its arrowhead grows with it, and the room an edge's text makes for itself counts the arrowhead it will actually have, so thickening a line with text between two close nodes can push them apart — the same way a larger text grows its node. Color, path, corners, crossings and pattern move nothing.
613
+
490
614
  ## Against a part of a node
491
615
 
492
616
  ```
@@ -627,7 +751,7 @@ Every attribute, and what takes one. The kinds here are what a node's **body** i
627
751
  | `fill` | ✓ | | | | color — see "A color names the part it colors" |
628
752
  | `border` | ✓ | | | | color |
629
753
  | `text` | ✓ | ✓ | ✓ | ✓ | the text's properties, in brackets — a style's form of what a node or an edge writes after its own words |
630
- | `line` | | | | ✓ | color |
754
+ | `line` | | | | ✓ | color, or everything about the line in brackets — see "How the line is drawn" |
631
755
  | `url` | ✓ | ✓ | ✓ | ✓ | a destination to open when the thing is clicked |
632
756
 
633
757
  The `diagram` statement has a vocabulary of its own — `theme`, `background` and `text` — which is checked the same way. Writing `background:` on a node is an error that points at `fill:`, and `theme:` on one points at `diagram theme:`. A `default` takes a narrower list for each kind; see "Defaults".
@@ -831,7 +955,7 @@ Deliberate omissions. What they protect is that the renderer never *chooses* an
831
955
  - **Guessing an axis nobody constrained.** When two placements bind one axis and nothing binds the other, the tool refuses rather than picking a target to center on. Choosing there would decide which row a node shares, not how far it sits from something.
832
956
  - **Placements that run in a circle.** A loop where each placement demands more room than the last cannot be satisfied and is an error naming the placements involved. A target does *not* have to be positioned before the node naming it — the whole system is solved at once — so ordinary mutual references are fine.
833
957
  - **Edge waypoints.** A point a line must pass through is a coordinate wearing a hat. Saying a line goes between two named things is not one — it names things the diagram already contains, and it survives those things moving.
834
- - **Choosing a route.** The tool will not find its own way around an obstacle. A line that crosses something it should not is a line you have not yet said enough about, and `between` is how you say it.
958
+ - **Choosing a route.** The tool will not find its own way around an obstacle. A line that crosses something it should not is a line you have not yet said enough about, and `between` and `below <node>` are how you say it.
835
959
  - **Set-level placement.** Four siblings around a hub are four statements today. Whether a durable group that reflows when a member is added is worth the same-axis conflict it introduces is undecided.
836
960
 
837
961
  Note what is *not* on this list: saying more about where something goes. A statement that lets you be more precise is not a step toward auto-layout, and the first version was short enough of them to render the benchmark wrong.
@@ -840,7 +964,7 @@ Note what is *not* on this list: saying more about where something goes. A state
840
964
 
841
965
  Designed, decided, and absent from the code. Written down so the next version has somewhere to start.
842
966
 
843
- **Nothing keeps an edge clear of a node on its own.** Non-overlap applies to nodes only. A line may still cut across a node it has nothing to do with, and an edge text may still land on top of one. `between` is how you say where a line goes when that matters, and nothing checks the ones where you have not said. A check belongs on the diagnostics list, but finding a route by itself does not — see "What the language refuses".
967
+ **Nothing keeps an edge clear of a node on its own.** Non-overlap applies to nodes only. A line may still cut across a node it has nothing to do with, and an edge text may still land on top of one. `between` and `below <node>` are how you say where a line goes when that matters, and nothing checks the ones where you have not said. A check belongs on the diagnostics list, but finding a route by itself does not — see "What the language refuses".
844
968
 
845
969
  **An icon outside the built-in seven.** The set is closed, and a diagram wanting a picture that is not in it has nowhere to go. The two shapes this could take are a declaration in the file, `icon <name> "<path data>"` beside `style`, and `icon: ./thing.svg` inlined by the tool at render time. Either keeps the output standalone, which is the constraint any answer has to meet.
846
970
 
@@ -882,10 +1006,21 @@ That one was found by testing the lexer, not by rendering — and it could not h
882
1006
 
883
1007
  ~~An edge text ignored the line break.~~ Fixed. ` / ` split a node's text and was never applied to an edge's, so the marker came out as a literal slash on an arrow and the benchmark's two-line captions had to be flattened to one. The measurer had always returned the split lines; the renderer was handing it the raw string and drawing that instead. The block now centers on the point the text already occupied, so a one-line text sits exactly where it did.
884
1008
 
1009
+ ~~An edge whose ends faced away from each other was drawn through its own nodes.~~ Fixed. `from: left to: right` with the far node further right was one curve bending out at both ends, and with the two nodes in a row it flattened into a straight line through both of them and anything between, its text landing on whatever was in the middle. It was not a matter of exact alignment: a node stepped down a little drew a tilted version of the same line. Such an edge now turns back in the gap between its nodes if there is room, and goes over the top of the row if not. See "Ends that face away from each other".
1010
+
885
1011
  ## Changelog
886
1012
 
887
1013
  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.
888
1014
 
1015
+ **0.7.0**
1016
+
1017
+ - `line:` takes a bracket for everything about how the line is drawn: `path: curved | square | straight`, `corners: sharp | rounded`, `crossing: none | arc | gap | square`, `pattern: solid | dashed | dotted | dash-dot` and `thickness: thin | normal | thick` or a number. `line: red` still works, as the short form of `line: (color: red)`. Every default is how lines were drawn before, so no existing file changes.
1018
+
1019
+ **0.6.0**
1020
+
1021
+ - An edge whose ends face away from each other, such as `from: left to: right` with the far node to the right, goes around instead of through its own nodes: through the gap between them if there is room, over the top of the row if not. Several over one row take a lane each. Sides at right angles, such as `from: left to: top`, go around the same way when one faces away.
1022
+ - `above`, `below`, `left of` and `right of` on an edge say which side of a node the line passes: `edge a -> b below c`. Any number, one per node; one naming several nodes covers the stretch between them too. One edge may mix the two kinds, `below m left of n`, and the line turns between them as often as they need. Clauses that cannot all be drawn are refused with the reason.
1023
+
889
1024
  **0.5.0**
890
1025
 
891
1026
  - Named themes: `diagram theme: nord`, one of thirteen, and `--theme` on the command line to render a file in another without editing it. The playground has a theme picker, which writes the `theme:` line into the source.
@@ -920,6 +1055,7 @@ Pre-1.0, so the minor number is where a breaking change goes. Every removal belo
920
1055
 
921
1056
  Open questions the benchmark raised, recorded so a later session does not rediscover them.
922
1057
 
1058
+ - One edge passing some nodes above or below and others left or right — an L-shaped line, across under one node and then down past another — is refused today. Often the picture leaves only one order for the two legs; where two orders both fit, the tool must not pick one, and whether it asks for another clause or follows a standing rule is not decided.
923
1059
  - Named gaps are the first step toward numbers, but making them minimums took most of the pressure off: they now set how much a diagram breathes, never whether something fits. Whether four names is the right number is still open.
924
1060
  - Four machines each holding a `files` child with the same text means writing the same line four times. This is the strongest case for a set-level declaration, for terseness rather than for placement.
925
1061
  - The 2×2 arrangement around a hub is four independent statements, so a fifth machine has no slot to reflow into. There are only eight directions.
package/dist/ast.d.ts CHANGED
@@ -241,6 +241,24 @@ export type Attrs = Record<string, string>;
241
241
  * `align` is how its lines range against each other once it is there.
242
242
  */
243
243
  export declare const TEXT_KEYS: readonly ["color", "size", "wrap", "align", "at"];
244
+ /**
245
+ * What an edge's line takes in its bracket: `line: (path: square, pattern:
246
+ * dashed)`. The line is a part in the sense the text is, so every property of
247
+ * it goes in one bracket, as the text's do. `line: red` stays as the short form
248
+ * of `line: (color: red)`, and both are stored under the bare `line` key.
249
+ */
250
+ export declare const LINE_KEYS: readonly ["color", "path", "corners", "crossing", "pattern", "thickness"];
251
+ /**
252
+ * The words each of the line's properties takes, the default first. Thickness
253
+ * takes a plain number of pixels as well.
254
+ */
255
+ export declare const LINE_VALUES: {
256
+ readonly path: readonly ["curved", "square", "straight"];
257
+ readonly corners: readonly ["sharp", "rounded"];
258
+ readonly crossing: readonly ["none", "arc", "gap", "square"];
259
+ readonly pattern: readonly ["solid", "dashed", "dotted", "dash-dot"];
260
+ readonly thickness: readonly ["normal", "thin", "thick"];
261
+ };
244
262
  export interface NodeStmt {
245
263
  kind: 'node';
246
264
  name: string;
@@ -269,6 +287,13 @@ export interface EdgeStmt {
269
287
  textAttrs: Attrs;
270
288
  /** `between desktop1 and laptop1` — the gap the line passes through. */
271
289
  between?: Passage;
290
+ /**
291
+ * `below resolver`, `left of a and b` — which side of a node the line is on
292
+ * where it passes that node. The placement words, because it is the same
293
+ * statement about the picture; on an edge it binds only the stretch where the
294
+ * line is passing, as `between` does. Always `kind: 'offset'` with no gap.
295
+ */
296
+ passes?: OffsetPlacement[];
272
297
  attrs: Attrs;
273
298
  line: number;
274
299
  }
package/dist/ast.js CHANGED
@@ -222,6 +222,24 @@ export function describeAxis(axis) {
222
222
  * `align` is how its lines range against each other once it is there.
223
223
  */
224
224
  export const TEXT_KEYS = ['color', 'size', 'wrap', 'align', 'at'];
225
+ /**
226
+ * What an edge's line takes in its bracket: `line: (path: square, pattern:
227
+ * dashed)`. The line is a part in the sense the text is, so every property of
228
+ * it goes in one bracket, as the text's do. `line: red` stays as the short form
229
+ * of `line: (color: red)`, and both are stored under the bare `line` key.
230
+ */
231
+ export const LINE_KEYS = ['color', 'path', 'corners', 'crossing', 'pattern', 'thickness'];
232
+ /**
233
+ * The words each of the line's properties takes, the default first. Thickness
234
+ * takes a plain number of pixels as well.
235
+ */
236
+ export const LINE_VALUES = {
237
+ path: ['curved', 'square', 'straight'],
238
+ corners: ['sharp', 'rounded'],
239
+ crossing: ['none', 'arc', 'gap', 'square'],
240
+ pattern: ['solid', 'dashed', 'dotted', 'dash-dot'],
241
+ thickness: ['normal', 'thin', 'thick'],
242
+ };
225
243
  /**
226
244
  * The attributes a `diagram` statement understands. `text` takes a bracket,
227
245
  * `text: (color: …)`, and sets the text color of everything at once — the
@@ -50,6 +50,15 @@ export declare const ARROW_MARKER_WIDTH = 7;
50
50
  * agreed by coincidence is a number that drifts.
51
51
  */
52
52
  export declare const ARROW_LENGTH: number;
53
+ /** What `thickness: thin | normal | thick` draw, in pixels. `normal` is the line as it always was. */
54
+ export declare const THICKNESS: Record<string, number>;
55
+ /**
56
+ * How much of a line of this thickness its arrowhead covers. The marker scales
57
+ * with the stroke, so a thick line gets a bigger head, and the room an edge's
58
+ * text makes for itself counts the head it will actually be drawn with — a
59
+ * thickness is a size, like a text's, and a size is allowed to take room.
60
+ */
61
+ export declare function arrowLength(thickness: number): number;
53
62
  /**
54
63
  * Line left showing between an edge's text and the box at that end of the
55
64
  * corridor it crosses.
package/dist/constants.js CHANGED
@@ -56,6 +56,21 @@ export const ARROW_MARKER_WIDTH = 7;
56
56
  * agreed by coincidence is a number that drifts.
57
57
  */
58
58
  export const ARROW_LENGTH = ARROW_MARKER_WIDTH * LINE_WIDTH;
59
+ /** What `thickness: thin | normal | thick` draw, in pixels. `normal` is the line as it always was. */
60
+ export const THICKNESS = {
61
+ thin: 1,
62
+ normal: LINE_WIDTH,
63
+ thick: LINE_WIDTH * 2,
64
+ };
65
+ /**
66
+ * How much of a line of this thickness its arrowhead covers. The marker scales
67
+ * with the stroke, so a thick line gets a bigger head, and the room an edge's
68
+ * text makes for itself counts the head it will actually be drawn with — a
69
+ * thickness is a size, like a text's, and a size is allowed to take room.
70
+ */
71
+ export function arrowLength(thickness) {
72
+ return ARROW_MARKER_WIDTH * thickness;
73
+ }
59
74
  /**
60
75
  * Line left showing between an edge's text and the box at that end of the
61
76
  * corridor it crosses.
package/dist/model.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Attrs, Axis, Kind, Placement } from './ast.js';
1
+ import type { Attrs, Axis, Direction, Kind, Placement } from './ast.js';
2
2
  import type { Line } from './text.js';
3
3
  import type { Body } from './icons.js';
4
4
  /** A `between` clause with its targets resolved. Mirrors `Passage` in `ast.ts`. */
@@ -7,6 +7,17 @@ export interface LayoutPassage {
7
7
  /** Which gap, where the pair has two. Absent when the pair leaves no doubt. */
8
8
  axis?: Axis;
9
9
  }
10
+ /**
11
+ * `below resolver` on an edge with its targets resolved: the line is on that
12
+ * side of the box bounding `nodes`, where it passes them. Mirrors the edge's
13
+ * `passes` in `ast.ts`.
14
+ */
15
+ export interface LayoutPass {
16
+ direction: Direction;
17
+ nodes: LayoutNode[];
18
+ /** As the author wrote it, for error messages. */
19
+ written: string;
20
+ }
10
21
  /** A node with its geometry solved. Coordinates are absolute, origin top-left. */
11
22
  /** A distance past each side of a box. */
12
23
  export interface Reach {
@@ -116,10 +127,26 @@ export interface LayoutEdge {
116
127
  * rather than solved for, so edges stay out of the constraint system entirely.
117
128
  */
118
129
  between?: LayoutPassage;
130
+ /** Which side of which nodes the line passes. Measured in the renderer, like `between`. */
131
+ passes?: LayoutPass[];
132
+ /** What the line's bracket says, checked and with every default filled in. */
133
+ look: LineLook;
119
134
  attrs: Attrs;
120
135
  appearance: Attrs;
121
136
  line: number;
122
137
  }
138
+ /**
139
+ * How an edge's line is drawn: `line: (path: square, corners: rounded, …)`.
140
+ * Only `thickness` takes room; the rest is appearance and moves nothing.
141
+ */
142
+ export interface LineLook {
143
+ path: 'curved' | 'square' | 'straight';
144
+ corners: 'sharp' | 'rounded';
145
+ crossing: 'none' | 'arc' | 'gap' | 'square';
146
+ pattern: 'solid' | 'dashed' | 'dotted' | 'dash-dot';
147
+ /** In pixels. */
148
+ thickness: number;
149
+ }
123
150
  export interface Layout {
124
151
  /** Every node, containers and children alike, in declaration order. */
125
152
  nodes: LayoutNode[];
package/dist/parser.js CHANGED
@@ -1,4 +1,4 @@
1
- import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
1
+ import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, LINE_KEYS, LINE_VALUES, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
2
2
  import { SourceError } from './errors.js';
3
3
  import { isAttrKey, tokenizeLine } from './lexer.js';
4
4
  import { THEME_NAMES, THEMES } from './themes.js';
@@ -138,12 +138,25 @@ function parseTail(tokens, start, line, subject, other) {
138
138
  const BRACKET_KEYS = {
139
139
  text: TEXT_KEYS,
140
140
  contents: CONTENTS_KEYS,
141
+ line: LINE_KEYS,
141
142
  };
142
143
  /** How each bracketed key's error quotes itself back, and what it is about. */
143
144
  const BRACKET_ABOUT = {
144
145
  text: { kind: 'a text', example: 'color: muted' },
145
146
  contents: { kind: 'a `contents:` bracket', example: 'widths: match' },
147
+ line: { kind: 'a line', example: 'path: square' },
146
148
  };
149
+ /**
150
+ * Which of the line's properties a word belongs to, so that `line: square` —
151
+ * the color key given a shape — can be pointed at the bracket.
152
+ */
153
+ function linePropertyOf(word) {
154
+ for (const [property, words] of Object.entries(LINE_VALUES)) {
155
+ if (words.includes(word) && word !== 'normal')
156
+ return property;
157
+ }
158
+ return undefined;
159
+ }
147
160
  /**
148
161
  * The top-level keys that moved into the text's bracket in 0.3.0, and the
149
162
  * substitution each one gets. They are properties of a node's *text* and never
@@ -186,7 +199,11 @@ function readAttr(tokens, at, attrs, line, subject) {
186
199
  // Two brackets for one part are fine as long as they say different things;
187
200
  // the same property in both is the same defect as `fill:` written twice.
188
201
  for (const [inner, value] of Object.entries(read.values)) {
189
- setOnce(attrs, `${key}.${inner}`, value, subject, line, { key: `${key}: (${inner}: …)`, value: (v) => v });
202
+ // A line's color is stored where `line: red` puts it, so the short form
203
+ // and the bracket are one key: a style's `line: red` merges under an
204
+ // edge's `line: (color: blue)`, and writing both on one line is a repeat.
205
+ const stored = key === 'line' && inner === 'color' ? 'line' : `${key}.${inner}`;
206
+ setOnce(attrs, stored, value, subject, line, { key: `${key}: (${inner}: …)`, value: (v) => v });
190
207
  }
191
208
  return read.next;
192
209
  }
@@ -251,7 +268,7 @@ function readAttr(tokens, at, attrs, line, subject) {
251
268
  });
252
269
  return next;
253
270
  }
254
- if (bracketKeys !== undefined && key !== 'text') {
271
+ if (bracketKeys !== undefined && key !== 'text' && key !== 'line') {
255
272
  // `contents: match` names the part and then says one of its two properties
256
273
  // without saying which. The brackets are what make the level shift visible,
257
274
  // so there is no unbracketed spelling to fall back to.
@@ -292,6 +309,15 @@ function readAttr(tokens, at, attrs, line, subject) {
292
309
  ? `\`text:\` is how a style says something about text, not how anything sets it — write the words in quotes after the name, as in \`node name ${quoteOf(valueToken.text)}\``
293
310
  : `\`text:\` takes the text's properties in brackets — write \`text: (color: ${valueToken.text})\` in a style, and \`(color: ${valueToken.text})\` in the brackets after a node's or an edge's own text`, line);
294
311
  }
312
+ if (key === 'line' && !valueToken.quoted) {
313
+ // `line: red` is the short form of the color, and the only one: every other
314
+ // property of the line needs the bracket to say which it is.
315
+ const property = linePropertyOf(valueToken.text) ?? (/^\d/.test(valueToken.text) ? 'thickness' : undefined);
316
+ if (property !== undefined) {
317
+ throw new SourceError(`\`line: ${valueToken.text}\` — \`line:\` on its own takes a color. The line's other properties go in its bracket: ` +
318
+ `\`line: (${property}: ${valueToken.text})\``, line);
319
+ }
320
+ }
295
321
  if (key === 'align' && valueToken.text === 'widths') {
296
322
  // Removed in 0.3.0. It was a size operation wearing an alignment's name,
297
323
  // and its value set had one member — a flag in a property's clothes. Its
@@ -461,9 +487,26 @@ function parseEdge(head, line) {
461
487
  between = { targets: read.targets, ...(axis !== undefined ? { axis } : {}) };
462
488
  return next;
463
489
  });
464
- if (tail.placements.length > 0) {
465
- throw new SourceError(`${subject}: "${describePlacement(tail.placements[0])}" places a node, and an edge is not ` +
466
- 'placed — it joins two things that are', line);
490
+ // An edge is not placed, so a direction on its line says which side of that
491
+ // node the line passes. The other placements say where a thing *is*, and
492
+ // have no reading for a line.
493
+ const passes = [];
494
+ for (const placement of tail.placements) {
495
+ const written = describePlacement(placement);
496
+ if (placement.kind !== 'offset' || placement.written !== undefined) {
497
+ throw new SourceError(`${subject}: "${written}" places a node, and an edge is not placed — it joins two things ` +
498
+ 'that are. On an edge, above, below, left of and right of say which side of a node the ' +
499
+ 'line passes', line);
500
+ }
501
+ if (placement.gap !== undefined) {
502
+ throw new SourceError(`${subject}: "${written}" gives a gap, and a gap is kept between nodes — the line passes ` +
503
+ 'as close as it reads clearly. Drop the brackets', line);
504
+ }
505
+ passes.push(placement);
506
+ }
507
+ if (passes.length > 0 && between) {
508
+ throw new SourceError(`${subject}: "between" and "${describePlacement(passes[0])}" on one edge — an edge passing ` +
509
+ 'between two things already has a side of each, so say one or the other', line);
467
510
  }
468
511
  refuseTextKey(tail.attrs, subject, 'after the arrow', line);
469
512
  return {
@@ -474,6 +517,7 @@ function parseEdge(head, line) {
474
517
  both: arrow.text === '<->',
475
518
  ...(textToken ? { text: textToken.text } : {}),
476
519
  ...(between ? { between } : {}),
520
+ ...(passes.length > 0 ? { passes } : {}),
477
521
  attrs: tail.attrs,
478
522
  line,
479
523
  };