reladraw 0.19.0 → 0.20.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 +4 -4
- package/SYNTAX.md +35 -6
- package/dist/ast.d.ts +17 -0
- package/dist/ast.js +6 -0
- package/dist/checks.d.ts +1 -0
- package/dist/checks.js +79 -5
- package/dist/grammar.js +2 -0
- package/dist/lines.d.ts +1 -0
- package/dist/lines.js +10 -4
- package/dist/model.d.ts +5 -1
- package/dist/parser.js +48 -1
- package/dist/place.d.ts +55 -1
- package/dist/place.js +236 -10
- package/dist/resolve.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
reladraw is a text language for diagrams where you say where things go.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> The language is changing frequently. The playground always runs the newest syntax, so a diagram that draws there today may need a small edit later. An installed copy from npm changes only when you upgrade it.
|
|
6
|
+
|
|
7
|
+
**[Try it in your browser in the reladraw playground →](https://reladraw.github.io/reladraw/)**
|
|
6
8
|
|
|
7
9
|
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
10
|
|
|
@@ -86,9 +88,7 @@ That gets you a copy of the skill at the time you run it, so you'll need to re-r
|
|
|
86
88
|
|
|
87
89
|
## Status
|
|
88
90
|
|
|
89
|
-
Version 0.
|
|
90
|
-
|
|
91
|
-
The language isn't stable yet, so expect the syntax to change.
|
|
91
|
+
Version 0.20.0. 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, and an element that does the same inside a web page.
|
|
92
92
|
|
|
93
93
|
## License
|
|
94
94
|
|
package/SYNTAX.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Syntax reference — 0.
|
|
1
|
+
# Syntax reference — 0.20.0
|
|
2
2
|
|
|
3
3
|
What the language accepts. The changelog at the end lists what changed in each published version; 0.17.0's changes are large, and its entry has a table of every old spelling and what to write instead.
|
|
4
4
|
|
|
@@ -220,7 +220,7 @@ The text keeps its own position words, so `node d1 "Desktop 1 / Linux" (inside t
|
|
|
220
220
|
|
|
221
221
|
With an empty text, `node flag "" badge: disk`, the node is exactly the picture and its padding, since an empty text takes no room.
|
|
222
222
|
|
|
223
|
-
**A picture that is a thing of its own is a child**, placed like any other: `node drive.mark icon: disk
|
|
223
|
+
**A picture that is a thing of its own is a child**, placed like any other: `node drive.mark icon: disk 1 right of drive text` beside the text. Placed against the text without `join-with:`, it is not part of the label: the text stays where it would be without it, and the box grows to hold both. In the corner of a box its other children size, `node laptop.mark icon: laptop inside laptop top-right stays above laptop.photos` ([Against its own parent](#against-its-own-parent)). A corner of a box with nothing in it but its text is on the text.
|
|
224
224
|
|
|
225
225
|
### Padding
|
|
226
226
|
|
|
@@ -566,7 +566,20 @@ A unit that includes a box's text is placed by the text's position words: on a l
|
|
|
566
566
|
|
|
567
567
|
### Against its own parent
|
|
568
568
|
|
|
569
|
-
A child placed against its parent's side or corner, `inside server right` or `inside server top-left`, is placed against the finished box and never grows it. If it covers something, that is reported. To make the box hold something, place it relative to the other children
|
|
569
|
+
A child placed against its parent's side or corner, `inside server right` or `inside server top-left`, is placed against the finished box and never grows it. That holds one axis at a time: `node c.j "j" right of c.k inside c top` is beside `c.k` across, so the box holds it across, and against the box's top down. A corner and a sibling saying the same axis, `inside c top-right right of c.k`, are refused: the error offers `right of c.k inside c top` and `inside c top-right stays right of c.k` (below). If it covers something, that is reported. So is a child the box has no room for in that corner, such as a two-line picture in the corner of a box one line tall, as `node-overflow`; the box doesn't grow to fit it, since a box only just tall enough would put it level with the middle, not in the corner. To make the box hold something, place it relative to the other children, or keep it in the corner and clear of them with `stays`. Something the box holds may not be placed against a thing on its own side or corner, since the box would then depend on itself; that is refused.
|
|
570
|
+
|
|
571
|
+
**`stays` keeps a child clear of the rest of its box.** A picture in the corner of a box its children size can land on one of them. `stays above`, `stays below`, `stays left of` and `stays right of` say which way to keep clear:
|
|
572
|
+
|
|
573
|
+
```
|
|
574
|
+
node laptop "Laptop" (inside top, align: left)
|
|
575
|
+
node laptop.docs "Documents"
|
|
576
|
+
node laptop.photos "Photos" right of laptop.docs
|
|
577
|
+
node laptop.mark icon: laptop inside laptop top-right stays above laptop.photos
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
The laptop is still in the corner; `stays` never says where something goes. It says the laptop is at least the usual gap above Photos, and when it isn't, the box makes room: everything else it holds moves down together, so no gap a placement states changes, and the box grows by just enough. `(gap: wide)` or a number after the target asks for a different distance. Beside the box's own text, `stays right of drive text`, the box widens until the picture clears the text.
|
|
581
|
+
|
|
582
|
+
A limit relates two things in one box that nothing else ties together: a child placed against the box's side or corner and another child, or the box's text; two children in different corners; something beside the text and the children below it. Between two children placed against each other, or across boxes, every distance is already stated, so a limit there is refused. So is one the box can't meet by growing, such as `inside laptop bottom-right stays above laptop.photos`, and one a stated size leaves no room for.
|
|
570
583
|
|
|
571
584
|
A title inside the border counts as content, so the box makes room for it. So does anything placed against the text. A title outside the border hangs off the box and doesn't count.
|
|
572
585
|
|
|
@@ -842,7 +855,7 @@ A line named `req` has parts. `req start` and `req end` are where it was written
|
|
|
842
855
|
|
|
843
856
|
### Copies fan out
|
|
844
857
|
|
|
845
|
-
Copies are lines with the same two boxes on the same sides, in either direction, with nothing stated about their path. Texts and colors don't count, so a request and its response are copies. Copies are drawn as one shape, each shifted sideways from it by a fixed step like lanes on a road, so they never cross. Straight copies with no side named run parallel, a `tight` gap apart, each end landing where its lane meets the border. A line
|
|
858
|
+
Copies are lines with the same two boxes on the same sides, in either direction, with nothing stated about their path. Texts and colors don't count, so a request and its response are copies. Copies are drawn as one shape, each shifted sideways from it by a fixed step like lanes on a road, so they never cross. Straight copies with no side named run parallel, a `tight` gap apart, each end landing where its lane meets the border. A line that says anything about where it goes is no longer a copy, and is drawn as written: a stated landing (`to: top, right of z`), a place in a side's `right-edge-order:` list, or a `step-out:` or `turn:` in its own bracket. That is how to override the fan. A `step-out:` or `turn:` from `default edge` or a style says the same for every line, so copies sharing it still fan, each taking it from the first.
|
|
846
859
|
|
|
847
860
|
```
|
|
848
861
|
node client
|
|
@@ -1016,7 +1029,7 @@ The default order: a container before its own children, boxes before lines, an e
|
|
|
1016
1029
|
| `(color: …)` after a text | the text | any node, an edge; `text: (color: …)` in a style or default |
|
|
1017
1030
|
| `line` | the line and its end marks | an edge |
|
|
1018
1031
|
|
|
1019
|
-
A key means one thing everywhere, so it can be wrong: `border:` on a node with no outline is refused, and the error lists what that kind has. A color is written as the viewer receives it: a hex value, any CSS color, or a theme color. There is no list of color words: `green` works because it is a CSS color. A quoted color is refused
|
|
1032
|
+
A key means one thing everywhere, so it can be wrong: `border:` on a node with no outline is refused, and the error lists what that kind has. A color is written as the viewer receives it: a hex value, any CSS color, or a theme color. There is no list of color words: `green` works because it is a CSS color. A quoted color is refused, and so is a hex value without its `#`. `none` means the part isn't painted: `fill: none`.
|
|
1020
1033
|
|
|
1021
1034
|
**Changing a color never moves anything.** Size is different: a bigger text or a thicker line takes more room.
|
|
1022
1035
|
|
|
@@ -1126,7 +1139,8 @@ An **error** means the file can't be drawn: an unknown key, two placements on on
|
|
|
1126
1139
|
| `edge-node-overlap` | a line across a node that isn't one of its ends, or through its own ends | on |
|
|
1127
1140
|
| `edge-overlap` | two lines on one run for a stretch, however short; a collapsed bundle's bus, and lines from one statement such as `edge a -> b, c`, shared on purpose, are not reported | on |
|
|
1128
1141
|
| `crowded-edge-ends` | several line ends bunched on a side too short to space them: two ends' marks would touch, or, where neither has a mark, the lines come within a third of a unit | on |
|
|
1129
|
-
| `node-overlap` | two nodes on one spot that no layout, `inside`, `on` or `overlap: allow` accounts for | on |
|
|
1142
|
+
| `node-overlap` | two nodes on one spot that no layout, `inside`, `on` or `overlap: allow` accounts for; `inside hub top-right` accounts for covering `hub`, not `hub`'s other children, and a box that draws nothing (no fill, border or text) can't be covered | on |
|
|
1143
|
+
| `node-overflow` | a node placed against its container's side or corner that the container has no room for there: less room on the far side than it keeps from the side it names | on |
|
|
1130
1144
|
| `edge-no-length` | a line between two boxes that touch, drawn as its marks at the seam | on |
|
|
1131
1145
|
| `edge-crossings` | two lines crossing | off |
|
|
1132
1146
|
|
|
@@ -1281,6 +1295,21 @@ Designed and absent from the code.
|
|
|
1281
1295
|
|
|
1282
1296
|
Pre-1.0, so the minor number is where a breaking change goes. Every removal 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.
|
|
1283
1297
|
|
|
1298
|
+
**0.20.0**
|
|
1299
|
+
|
|
1300
|
+
- **`stays above`, `stays below`, `stays left of` and `stays right of`** keep a child placed in its box's corner clear of the other children: `node laptop.mark icon: laptop inside laptop top-right stays above laptop.photos`. The laptop stays in the corner, and the box makes room. See [Against its own parent](#against-its-own-parent).
|
|
1301
|
+
- **Breaking: a corner and a sibling saying the same axis are refused.** `node c.j "j" inside c top-right right of c.k` used to be centered between the two, neither in the corner nor beside `c.k`, straddling the box's edge. The error offers `right of c.k inside c top` to sit beside `c.k`, and `stays right of c.k` to keep the corner. The same goes for `inside c top-right above c.k` up and down.
|
|
1302
|
+
- **A new finding, `node-overflow`**: a child placed in its box's corner or against its side that the box has no room for there, such as `node drive.mark icon: disk inside drive top-right` on a box one line tall. It used to run out of the box with nothing said.
|
|
1303
|
+
- **A child in its box's corner that covers one of the box's other children is reported** as `node-overlap`, with the `stays` that would keep it clear. Being placed `inside` a box used to excuse it from covering anything in that box, not only the box.
|
|
1304
|
+
- **A child beside a sibling and against its parent's top stays inside the parent.** `node c.j "j" right of c.k inside c top` used to be drawn wholly outside `c`, with nothing reported. Being against the parent's top only measures its height from the finished box; across, the box holds it like any other child.
|
|
1305
|
+
- **Breaking: a hex color without its `#` is refused.** `fill: 444444` used to be passed to the viewer as is, which drew nothing. The error says to write `#444444`.
|
|
1306
|
+
- **The playground saves a PNG** with Save PNG, at twice the picture's size, in the fonts the page shows it in. Download is now Save SVG beside it.
|
|
1307
|
+
|
|
1308
|
+
**0.19.1**
|
|
1309
|
+
|
|
1310
|
+
- **A copy that states its own `step-out:` or `turn:` keeps it.** Lines between the same two sides of the same two boxes fan into lanes, and each used to be drawn as the first one's shape shifted over, so a `step-out:` or `turn:` written on any later line was ignored. A line naming either is now drawn as written and leaves the fan, and so does a line named in its side's `right-edge-order:` list. Where that puts two lines on one run, it is reported as `edge-overlap`. Prompted by #19.
|
|
1311
|
+
- **Errors and findings name a named line by its name**, as written: `line e2 b.1 -- b.2 runs on line e1 b.1 -- b.2`. Two lines between the same boxes used to read identically.
|
|
1312
|
+
|
|
1284
1313
|
**0.19.0**
|
|
1285
1314
|
|
|
1286
1315
|
- **`reladraw format`** lays a file out again: each statement on one line, neighboring statements with the same keyword lined up in columns, comments kept beside what they are about. No word is respelled, and the result is checked to draw what the original did. `--check` fails on a file that would change, and `.reladraw-format` holds a project's settings; `reladraw format --init` writes one with every setting explained. `reladraw format -` reads standard input and prints the result, for editors. A long statement can wrap past a width (`wrap-at`) or go one clause to a line (`clause-per-line-at`), and `clause-order` puts placements and keys in a chosen order. The playground has a Format button (Shift+Alt+F, undone with Ctrl+Z) and a panel for its settings, which can copy them as a `.reladraw-format`. Spacing inside a clause is evened out too: `gap:tight` becomes `gap: tight`, `( gap: tight )` becomes `(gap: tight)`. Prompted by #14, where a long statement was hard to read in a plain editor.
|
package/dist/ast.d.ts
CHANGED
|
@@ -234,6 +234,21 @@ export interface OnPlacement {
|
|
|
234
234
|
* it needs; the resolver intersects them.
|
|
235
235
|
*/
|
|
236
236
|
export type Placement = OffsetPlacement | AlignPlacement | OnPlacement;
|
|
237
|
+
/**
|
|
238
|
+
* `stays above laptop.photos (gap: wide)` — a limit, not a placement
|
|
239
|
+
* (design.org 5.6). It never says where the node goes, so no axis counts it;
|
|
240
|
+
* it rules out pictures where the node is less than a gap that way from its
|
|
241
|
+
* target, and the box holding both makes room by lengthening the one distance
|
|
242
|
+
* nobody stated.
|
|
243
|
+
*/
|
|
244
|
+
export interface Limit {
|
|
245
|
+
way: 'above' | 'below' | 'left' | 'right';
|
|
246
|
+
target: PlacementTarget;
|
|
247
|
+
gap?: string;
|
|
248
|
+
line: number;
|
|
249
|
+
}
|
|
250
|
+
/** How a limit reads back in the author's own words. */
|
|
251
|
+
export declare function describeLimit(limit: Limit): string;
|
|
237
252
|
/**
|
|
238
253
|
* The modifiers a placement understands, in brackets after its targets. Refused
|
|
239
254
|
* by name when unrecognized, for the reason `DIAGRAM_KEYS` are: a modifier that
|
|
@@ -368,6 +383,8 @@ export interface NodeStmt {
|
|
|
368
383
|
titlePlacements?: Placement[];
|
|
369
384
|
/** Everything the author said about where this goes. Empty for the anchor. */
|
|
370
385
|
placements: Placement[];
|
|
386
|
+
/** `stays above laptop.photos`: what it keeps clear of (design.org 5.6). */
|
|
387
|
+
limits?: Limit[];
|
|
371
388
|
attrs: Attrs;
|
|
372
389
|
/** `guide node frame ""`: does everything a node does except draw (design.org 8.1). */
|
|
373
390
|
guide?: boolean;
|
package/dist/ast.js
CHANGED
|
@@ -160,6 +160,12 @@ export function describePoint(point) {
|
|
|
160
160
|
const of = /left|right/.test(point.direction) ? ' of' : '';
|
|
161
161
|
return `${point.distance} ${point.direction}${of} ${nameTarget(point.from)}`;
|
|
162
162
|
}
|
|
163
|
+
/** How a limit reads back in the author's own words. */
|
|
164
|
+
export function describeLimit(limit) {
|
|
165
|
+
const of = limit.way === 'left' || limit.way === 'right' ? ' of' : '';
|
|
166
|
+
const gap = limit.gap === undefined ? '' : ` (gap: ${limit.gap})`;
|
|
167
|
+
return `stays ${limit.way}${of} ${nameTarget(limit.target)}${gap}`;
|
|
168
|
+
}
|
|
163
169
|
/**
|
|
164
170
|
* The modifiers a placement understands, in brackets after its targets. Refused
|
|
165
171
|
* by name when unrecognized, for the reason `DIAGRAM_KEYS` are: a modifier that
|
package/dist/checks.d.ts
CHANGED
package/dist/checks.js
CHANGED
|
@@ -25,6 +25,7 @@ export const CHECK_DEFAULTS = {
|
|
|
25
25
|
'edge-overlap': true,
|
|
26
26
|
'crowded-edge-ends': true,
|
|
27
27
|
'node-overlap': true,
|
|
28
|
+
'node-overflow': true,
|
|
28
29
|
'edge-no-length': true,
|
|
29
30
|
'edge-crossings': false,
|
|
30
31
|
};
|
|
@@ -96,6 +97,8 @@ export function findings(layout, checks) {
|
|
|
96
97
|
const found = [];
|
|
97
98
|
if (checks['node-overlap'])
|
|
98
99
|
found.push(...nodeOverlaps(layout));
|
|
100
|
+
if (checks['node-overflow'])
|
|
101
|
+
found.push(...nodeOverflows(layout));
|
|
99
102
|
if (checks['text-overflow'])
|
|
100
103
|
found.push(...textOverflows(layout));
|
|
101
104
|
if (checks['edge-node-overlap'] || checks['edge-overlap'] || checks['edge-no-length'] || checks['crowded-edge-ends'] || checks['edge-crossings'] || checks['text-overlap']) {
|
|
@@ -781,12 +784,20 @@ function nodeOverlaps(layout) {
|
|
|
781
784
|
return true;
|
|
782
785
|
if (upA.some(saysOverlapAllowed) || upB.some(saysOverlapAllowed))
|
|
783
786
|
return true;
|
|
784
|
-
|
|
785
|
-
|
|
787
|
+
// `inside hub top-right` accounts for covering hub, and what it holds
|
|
788
|
+
// covering hub, but not for covering hub's other children (design.org 5.6).
|
|
789
|
+
const placedOn = (over, under) => over.some((node) => stated.get(node)?.has(under));
|
|
790
|
+
return placedOn(upA, b) || placedOn(upB, a);
|
|
786
791
|
};
|
|
787
792
|
const pairs = [];
|
|
788
|
-
// A guide box draws nothing, so nothing drawn can cover it or be covered by
|
|
789
|
-
|
|
793
|
+
// A guide box draws nothing, so nothing drawn can cover it or be covered by
|
|
794
|
+
// it, and neither can a box with no fill, no border, no picture and no
|
|
795
|
+
// text, such as a spacer holding its container open.
|
|
796
|
+
const blank = (node) => node.lines.every((line) => line.every((run) => run.text.trim() === '')) &&
|
|
797
|
+
node.body.kind !== 'icon' &&
|
|
798
|
+
node.appearance['fill'] === 'none' &&
|
|
799
|
+
node.appearance['border'] === 'none';
|
|
800
|
+
const sorted = layout.nodes.filter((node) => !node.guide && !blank(node)).sort((a, b) => a.x - b.x);
|
|
790
801
|
let active = [];
|
|
791
802
|
for (const node of sorted) {
|
|
792
803
|
active = active.filter((other) => other.x + other.width > node.x + EPSILON);
|
|
@@ -809,7 +820,70 @@ function nodeOverlaps(layout) {
|
|
|
809
820
|
const inner = ({ earlier, later }) => lineage(earlier).some((a) => lineage(later).some((b) => (a !== earlier || b !== later) && reported.has(namePair(a, b))));
|
|
810
821
|
return pairs
|
|
811
822
|
.filter((pair) => !inner(pair))
|
|
812
|
-
.map(({ earlier, later, width, height }) => new Finding('node-overlap', `node ${later.name} covers ${coveredPart(earlier, later)}node ${earlier.name}; ${size(width, height)}
|
|
823
|
+
.map(({ earlier, later, width, height }) => new Finding('node-overlap', `node ${later.name} covers ${coveredPart(earlier, later)}node ${earlier.name}; ${size(width, height)}` +
|
|
824
|
+
keepClear(earlier, later, width, height), later.line));
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* For a child placed against its container's side or corner over one of its
|
|
828
|
+
* siblings, the limit that would make the box room for it (design.org 5.6):
|
|
829
|
+
* toward the side it is against, which is the only way growing the box moves
|
|
830
|
+
* it, and of a corner's two, the one that clears the shorter overlap.
|
|
831
|
+
*/
|
|
832
|
+
function keepClear(a, b, width, height) {
|
|
833
|
+
const sides = framedSides;
|
|
834
|
+
if (a.parent === undefined || a.parent !== b.parent || (sides(a).length > 0) === (sides(b).length > 0))
|
|
835
|
+
return '';
|
|
836
|
+
const [own, other] = sides(a).length > 0 ? [a, b] : [b, a];
|
|
837
|
+
const words = { top: 'above', bottom: 'below', left: 'left of', right: 'right of' };
|
|
838
|
+
const ways = sides(own).map((side) => words[side]);
|
|
839
|
+
const way = ways.length === 1 ? ways[0] : height <= width ? ways.find((each) => each === 'above' || each === 'below') : ways.find((each) => each.endsWith('of'));
|
|
840
|
+
return ` — to keep ${own.name} clear of it, add "stays ${way} ${other.name}" to ${own.name}`;
|
|
841
|
+
}
|
|
842
|
+
/** The sides of its container a child is placed against: `inside c top-right` is top and right. */
|
|
843
|
+
function framedSides(node) {
|
|
844
|
+
const parent = node.parent;
|
|
845
|
+
if (parent === undefined)
|
|
846
|
+
return [];
|
|
847
|
+
const part = node.placements
|
|
848
|
+
.flatMap((placement) => placement.targets)
|
|
849
|
+
.find((target) => target.name === parent.name && target.part !== 'text')?.part;
|
|
850
|
+
if (part === undefined)
|
|
851
|
+
return [];
|
|
852
|
+
return part.split('-').filter((word) => ['top', 'bottom', 'left', 'right'].includes(word));
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* A child placed against its container's side or corner that the box has no
|
|
856
|
+
* room for there (design.org 5.6): less room on the far side than it keeps
|
|
857
|
+
* from the side it names, so it runs into the far side's padding or out of
|
|
858
|
+
* the box. The box never grows for it, so it is drawn as stated.
|
|
859
|
+
*/
|
|
860
|
+
function nodeOverflows(layout) {
|
|
861
|
+
const found = [];
|
|
862
|
+
for (const node of layout.nodes) {
|
|
863
|
+
const parent = node.parent;
|
|
864
|
+
if (parent === undefined || node.guide)
|
|
865
|
+
continue;
|
|
866
|
+
const short = [];
|
|
867
|
+
for (const side of framedSides(node)) {
|
|
868
|
+
const vertical = side === 'top' || side === 'bottom';
|
|
869
|
+
const start = vertical ? node.y : node.x;
|
|
870
|
+
const end = start + (vertical ? node.height : node.width);
|
|
871
|
+
const boxStart = vertical ? parent.y : parent.x;
|
|
872
|
+
const boxEnd = boxStart + (vertical ? parent.height : parent.width);
|
|
873
|
+
const kept = side === 'top' || side === 'left' ? start - boxStart : boxEnd - end;
|
|
874
|
+
const far = side === 'top' || side === 'left' ? boxEnd - end : start - boxStart;
|
|
875
|
+
if (far >= kept - EPSILON)
|
|
876
|
+
continue;
|
|
877
|
+
const room = (vertical ? parent.height : parent.width) - kept * 2;
|
|
878
|
+
const [measure, against] = vertical ? ['tall', 'height'] : ['wide', 'width'];
|
|
879
|
+
short.push(`it is ${units(vertical ? node.height : node.width)} ${measure} and ${parent.name}'s ${against} leaves ${room > 0 ? units(room) : 'nothing'} inside its padding`);
|
|
880
|
+
}
|
|
881
|
+
if (short.length === 0)
|
|
882
|
+
continue;
|
|
883
|
+
found.push(new Finding('node-overflow', `node ${node.name} has no room in the ${framedSides(node).join('-')} of ${parent.name}: ${short.join(', and ')} — ` +
|
|
884
|
+
`give ${parent.name} more room, or place ${node.name} against ${parent.name}'s text or its other children`, node.line));
|
|
885
|
+
}
|
|
886
|
+
return found;
|
|
813
887
|
}
|
|
814
888
|
function namePair(a, b) {
|
|
815
889
|
return a.name < b.name ? `${a.name}\n${b.name}` : `${b.name}\n${a.name}`;
|
package/dist/grammar.js
CHANGED
|
@@ -77,6 +77,8 @@ export const RELATION_WORDS = [
|
|
|
77
77
|
// `(inside top)`, `outside router right`, `via: inside p top`.
|
|
78
78
|
'inside',
|
|
79
79
|
'outside',
|
|
80
|
+
// `stays above laptop.photos`, a limit (design.org 5.6).
|
|
81
|
+
'stays',
|
|
80
82
|
// Lines (design.org 6): `straight down from z3`, `each child of pool`,
|
|
81
83
|
// `leg climb`, `via: through slot horizontal`.
|
|
82
84
|
'straight',
|
package/dist/lines.d.ts
CHANGED
|
@@ -101,4 +101,5 @@ export declare function crossingPoints(a: Point[], b: Point[], clear: number): {
|
|
|
101
101
|
along: Point;
|
|
102
102
|
other: Point;
|
|
103
103
|
}[];
|
|
104
|
+
/** An edge as written, its name first where it has one: `e2 b.1 -- b.2`, so two lines between the same boxes can be told apart. */
|
|
104
105
|
export declare function describeEdge(edge: LayoutEdge): string;
|
package/dist/lines.js
CHANGED
|
@@ -145,16 +145,20 @@ export function planSides(layout) {
|
|
|
145
145
|
/**
|
|
146
146
|
* Copies are lines with the same two boxes on the same sides, in either
|
|
147
147
|
* direction, with nothing stated about their path (6.5): no waypoints, no
|
|
148
|
-
* landing point, no place among a side's ends
|
|
149
|
-
*
|
|
148
|
+
* landing point, no place among a side's ends, and no `step-out:` or `turn:`
|
|
149
|
+
* written on the line itself. Found by filing each line under its boxes and
|
|
150
|
+
* sides, in one pass.
|
|
150
151
|
*/
|
|
151
152
|
function copySets(edges, ends) {
|
|
152
153
|
const filed = new Map();
|
|
153
|
-
const
|
|
154
|
+
const listed = (end) => end.edge.name !== undefined && (end.node.attrs[`${end.spec.side}-edge-order`]?.split(', ').includes(end.edge.name) ?? false);
|
|
155
|
+
const plain = (end) => end.spec.kind === 'side' && end.spec.after === undefined && !listed(end) && !end.onLine && end.edge.onMark?.[end.which] !== 'point';
|
|
154
156
|
const keyOf = (end) => `${end.node.name}\n${end.spec.side}`;
|
|
155
157
|
for (const edge of edges) {
|
|
156
158
|
if (edge.via !== undefined || edge.straight !== undefined)
|
|
157
159
|
continue;
|
|
160
|
+
if (edge.look.stepOutWritten !== undefined || edge.look.turnWritten !== undefined)
|
|
161
|
+
continue;
|
|
158
162
|
const [start, end] = ends.get(edge);
|
|
159
163
|
if (!plain(start) || !plain(end))
|
|
160
164
|
continue;
|
|
@@ -2007,8 +2011,10 @@ function intersect(a, b, c, d) {
|
|
|
2007
2011
|
return undefined;
|
|
2008
2012
|
return { x: a.x + r.x * t, y: a.y + r.y * t };
|
|
2009
2013
|
}
|
|
2014
|
+
/** An edge as written, its name first where it has one: `e2 b.1 -- b.2`, so two lines between the same boxes can be told apart. */
|
|
2010
2015
|
export function describeEdge(edge) {
|
|
2011
2016
|
if (edge.bundle)
|
|
2012
2017
|
return `bundle ${edge.name}`;
|
|
2013
|
-
|
|
2018
|
+
const ends = `${edge.from.name} ${edge.arrow} ${edge.to.name}`;
|
|
2019
|
+
return edge.name === undefined ? ends : `${edge.name} ${ends}`;
|
|
2014
2020
|
}
|
package/dist/model.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Attrs, EdgeTextPlace, Kind, Mark, NodeCorners, Placement, PlacementTarget, ViaEntry, Way } from './ast.js';
|
|
1
|
+
import type { Attrs, EdgeTextPlace, Kind, Limit, Mark, NodeCorners, Placement, PlacementTarget, ViaEntry, Way } from './ast.js';
|
|
2
2
|
/** A rectangle on the page; a side or a point has no size on one axis or both. */
|
|
3
3
|
export interface Rect {
|
|
4
4
|
x: number;
|
|
@@ -55,6 +55,8 @@ export interface LayoutNode {
|
|
|
55
55
|
* (design.org 7.2).
|
|
56
56
|
*/
|
|
57
57
|
titlePlacements?: Placement[];
|
|
58
|
+
/** `stays above laptop.photos`: what it keeps clear of (design.org 5.6). */
|
|
59
|
+
limits?: Limit[];
|
|
58
60
|
x: number;
|
|
59
61
|
y: number;
|
|
60
62
|
width: number;
|
|
@@ -287,6 +289,8 @@ export interface LineLook {
|
|
|
287
289
|
turnWritten: string | undefined;
|
|
288
290
|
/** The short first and last leg of a square or curved line, in pixels. */
|
|
289
291
|
stepOut: number;
|
|
292
|
+
/** `step-out:` as written on the edge itself; from a style or a default it is undefined. */
|
|
293
|
+
stepOutWritten: string | undefined;
|
|
290
294
|
}
|
|
291
295
|
export interface Layout {
|
|
292
296
|
/** Every node, containers and children alike, in declaration order. */
|
package/dist/parser.js
CHANGED
|
@@ -1238,7 +1238,15 @@ function parseNode(head, line) {
|
|
|
1238
1238
|
listed: NODE_TEXT_LISTED,
|
|
1239
1239
|
});
|
|
1240
1240
|
refuseNodeReads(bracket.values['reads'], subject, line);
|
|
1241
|
-
const
|
|
1241
|
+
const limits = [];
|
|
1242
|
+
const tail = parseTail(head, bracket.next, line, subject, (tokens, index) => {
|
|
1243
|
+
const word = tokens[index];
|
|
1244
|
+
if (word.quoted || word.text !== 'stays')
|
|
1245
|
+
return undefined;
|
|
1246
|
+
const read = readLimit(tokens, index, line, subject);
|
|
1247
|
+
limits.push(read.limit);
|
|
1248
|
+
return read.next;
|
|
1249
|
+
});
|
|
1242
1250
|
refuseTextKey(tail.attrs, subject, 'after the name', line);
|
|
1243
1251
|
return {
|
|
1244
1252
|
kind: 'node',
|
|
@@ -1248,10 +1256,43 @@ function parseNode(head, line) {
|
|
|
1248
1256
|
textAttrs: place === undefined ? bracket.values : { ...bracket.values, place },
|
|
1249
1257
|
...(titlePlacements.length > 0 ? { titlePlacements } : {}),
|
|
1250
1258
|
placements: tail.placements,
|
|
1259
|
+
...(limits.length > 0 ? { limits } : {}),
|
|
1251
1260
|
attrs: tail.attrs,
|
|
1252
1261
|
line,
|
|
1253
1262
|
};
|
|
1254
1263
|
}
|
|
1264
|
+
/**
|
|
1265
|
+
* `stays above laptop.photos (gap: wide)`: a limit (design.org 5.6). The words
|
|
1266
|
+
* after `stays` are read as a placement would be, then held to one straight
|
|
1267
|
+
* way and one target, since a limit says which side of one thing to keep to.
|
|
1268
|
+
*/
|
|
1269
|
+
function readLimit(tokens, at, line, subject) {
|
|
1270
|
+
const way = tokens[at + 1];
|
|
1271
|
+
const straight = ['above', 'below', 'left', 'right'];
|
|
1272
|
+
if (!way || way.quoted || !straight.includes(way.text)) {
|
|
1273
|
+
const diagonal = way !== undefined && !way.quoted && isDirection(way.text);
|
|
1274
|
+
throw new SourceError(diagonal
|
|
1275
|
+
? `${subject}: "stays ${way.text}" keeps to two sides at once — write one limit for each, such as \`stays above a stays right of a\``
|
|
1276
|
+
: `${subject}: "stays" is followed by the way it keeps to — stays above, stays below, stays left of or stays right of`, line);
|
|
1277
|
+
}
|
|
1278
|
+
const read = readPlacement(tokens, at + 1, line, subject, true);
|
|
1279
|
+
const placement = read.placement;
|
|
1280
|
+
if (placement.kind !== 'offset' || placement.own !== undefined || placement.gaps !== undefined) {
|
|
1281
|
+
throw new SourceError(`${subject}: "stays" names a way and one thing, such as \`stays above laptop.photos\``, line);
|
|
1282
|
+
}
|
|
1283
|
+
if (placement.targets.length !== 1) {
|
|
1284
|
+
throw new SourceError(`${subject}: "${describePlacement(placement)}" after "stays" names ${placement.targets.length} things — a limit keeps clear of one; write one for each`, line);
|
|
1285
|
+
}
|
|
1286
|
+
return {
|
|
1287
|
+
limit: {
|
|
1288
|
+
way: way.text,
|
|
1289
|
+
target: placement.targets[0],
|
|
1290
|
+
...(placement.gap !== undefined ? { gap: placement.gap } : {}),
|
|
1291
|
+
line,
|
|
1292
|
+
},
|
|
1293
|
+
next: read.next,
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1255
1296
|
/**
|
|
1256
1297
|
* A node's text takes the look keys and `reads:` (design.org 7.2). `at` is
|
|
1257
1298
|
* read only to be refused with the side form, so it isn't listed as one it takes.
|
|
@@ -2394,6 +2435,12 @@ function checkColorWord(value, subject, line) {
|
|
|
2394
2435
|
if (value.startsWith('theme-') && !THEME_COLORS.includes(value)) {
|
|
2395
2436
|
throw new SourceError(`${subject}: there is no theme color "${value}" — the theme colors are ${THEME_COLORS.join(', ')}`, line);
|
|
2396
2437
|
}
|
|
2438
|
+
// Hex digits without their `#` are no CSS color, so the viewer would draw nothing.
|
|
2439
|
+
if (/^([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value)) {
|
|
2440
|
+
// `bad` and `face` are spelled in hex digits too, so without a digit it is only a guess.
|
|
2441
|
+
const fix = /\d/.test(value) ? 'a hex color is written' : 'if it is a hex color, write';
|
|
2442
|
+
throw new SourceError(`${subject}: \`${value}\` is not a color — ${fix} \`#${value}\``, line);
|
|
2443
|
+
}
|
|
2397
2444
|
}
|
|
2398
2445
|
function follows(tokens, at, word) {
|
|
2399
2446
|
const token = tokens[at];
|
package/dist/place.d.ts
CHANGED
|
@@ -130,6 +130,8 @@ export declare class Placer {
|
|
|
130
130
|
private readonly sizes;
|
|
131
131
|
private readonly frames;
|
|
132
132
|
private readonly outlines;
|
|
133
|
+
/** A container's box while a limit tries it out (`roomFor`), in place of its final one. */
|
|
134
|
+
private readonly trials;
|
|
133
135
|
private readonly working;
|
|
134
136
|
/** What is being worked out, innermost last, for reporting a circle. */
|
|
135
137
|
private readonly trail;
|
|
@@ -181,6 +183,20 @@ export declare class Placer {
|
|
|
181
183
|
};
|
|
182
184
|
/** Work out everything, and write it onto the nodes. */
|
|
183
185
|
run(): void;
|
|
186
|
+
/**
|
|
187
|
+
* What a limit may relate (design.org 5.6): two things in one box that
|
|
188
|
+
* nothing else ties together, since the box's own spacing between them is
|
|
189
|
+
* the only distance it may lengthen.
|
|
190
|
+
*/
|
|
191
|
+
private checkLimitTargets;
|
|
192
|
+
/**
|
|
193
|
+
* Which of the things a box holds that nothing ties to each other a member
|
|
194
|
+
* belongs to, on one axis: its title, what is placed against the box's
|
|
195
|
+
* sides, or the rest, placed against each other.
|
|
196
|
+
*/
|
|
197
|
+
private groupOf;
|
|
198
|
+
/** A limit the box couldn't make room for: no side helps, or a stated size holds it in. */
|
|
199
|
+
private checkLimitsMet;
|
|
184
200
|
/** Where a node starts on the page. */
|
|
185
201
|
page(node: LayoutNode, axis: Axis): number;
|
|
186
202
|
/**
|
|
@@ -226,6 +242,26 @@ export declare class Placer {
|
|
|
226
242
|
private frame;
|
|
227
243
|
/** A container's outline around what it holds, before anything states its size. */
|
|
228
244
|
private outline;
|
|
245
|
+
/**
|
|
246
|
+
* A container's outline with room made for its children's limits
|
|
247
|
+
* (design.org 5.6). The only distance a limit may lengthen is between what
|
|
248
|
+
* the box holds and its sides, which every placement inside leaves unstated.
|
|
249
|
+
* Each side's room moves a limit's two ends apart at a fixed rate (all of
|
|
250
|
+
* it for a corner, half for the middle of a side), so one trial of each
|
|
251
|
+
* side gives the rate, and the room is the shortfall over the rate. A side
|
|
252
|
+
* that doesn't help is never used; with both helping, as for two corner
|
|
253
|
+
* children, the box grows evenly. A limit no side helps is left unmet, and
|
|
254
|
+
* `checkLimits` refuses it.
|
|
255
|
+
*/
|
|
256
|
+
private roomFor;
|
|
257
|
+
/** The limits a container's children state on one axis. */
|
|
258
|
+
private limitsIn;
|
|
259
|
+
/** How much nearer than its gap a limit's two ends are, in a trial frame of their container. */
|
|
260
|
+
private shortfall;
|
|
261
|
+
/** The distance a limit keeps: its gap, or the usual one; beside a text, as beside any text. */
|
|
262
|
+
private limitGap;
|
|
263
|
+
/** Where a child of `node` is on one axis, in a trial frame of `node`. */
|
|
264
|
+
private memberAt;
|
|
229
265
|
private remember;
|
|
230
266
|
/**
|
|
231
267
|
* Work something out, refusing a circle. The trail holds what was being
|
|
@@ -238,6 +274,14 @@ export declare class Placer {
|
|
|
238
274
|
private unitStart;
|
|
239
275
|
/** A box's start from its own placements. */
|
|
240
276
|
private placed;
|
|
277
|
+
/**
|
|
278
|
+
* Opposite placements center a box between two others, but not when one of
|
|
279
|
+
* them is its own container's box: `inside c top-right right of c.k` would
|
|
280
|
+
* be neither in the corner nor a gap from `c.k` (design.org 5.3). Refused,
|
|
281
|
+
* naming the two ways out: the corner's other half beside the sibling, or
|
|
282
|
+
* the corner kept and the sibling held off with `stays`.
|
|
283
|
+
*/
|
|
284
|
+
private refuseBoxAndSibling;
|
|
241
285
|
private edgeOf;
|
|
242
286
|
/** A box's start from one statement. */
|
|
243
287
|
private apply;
|
|
@@ -314,6 +358,15 @@ export declare class Placer {
|
|
|
314
358
|
private textOf;
|
|
315
359
|
/** A child placed against its own container's box, which it does not grow. */
|
|
316
360
|
framed(node: LayoutNode): boolean;
|
|
361
|
+
/**
|
|
362
|
+
* Framed on one axis: what says where it goes on that axis is against its
|
|
363
|
+
* container's box. `right of c.k inside c top` is framed down, so its height
|
|
364
|
+
* is measured from the finished box, and is content across, so the box holds
|
|
365
|
+
* it there (design.org 5.6).
|
|
366
|
+
*/
|
|
367
|
+
framedOn(node: LayoutNode, axis: Axis): boolean;
|
|
368
|
+
/** Whether a placement names its container's box, any part but the text. */
|
|
369
|
+
private againstBox;
|
|
317
370
|
/**
|
|
318
371
|
* A child placed against its container's text, or against its heading,
|
|
319
372
|
* which makes the title its frame's anchor.
|
|
@@ -334,7 +387,8 @@ export declare class Placer {
|
|
|
334
387
|
/**
|
|
335
388
|
* The children a container's box is the outline of, besides its title: not
|
|
336
389
|
* its heading, not what is joined to its text, and not those placed against
|
|
337
|
-
* the box.
|
|
390
|
+
* the box. Given an axis, a child placed against the box only on the other
|
|
391
|
+
* axis is content on this one.
|
|
338
392
|
*/
|
|
339
393
|
private contentOf;
|
|
340
394
|
/** Whether a node has a title: its text, what is joined to it, or a heading. */
|
package/dist/place.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describePlacement, describePoint, fractionAxis, nameTarget, PART_SIDES, partWordHint } from './ast.js';
|
|
1
|
+
import { describeLimit, describePlacement, describePoint, fractionAxis, nameTarget, PART_SIDES, partWordHint } from './ast.js';
|
|
2
2
|
import { DECK_STEP, GAPS, HEADER_GAP, UNIT } from './constants.js';
|
|
3
3
|
import { SourceError } from './errors.js';
|
|
4
4
|
import { isWithin, joinedToText } from './model.js';
|
|
@@ -107,6 +107,8 @@ export class Placer {
|
|
|
107
107
|
sizes = new Map();
|
|
108
108
|
frames = new Map();
|
|
109
109
|
outlines = new Map();
|
|
110
|
+
/** A container's box while a limit tries it out (`roomFor`), in place of its final one. */
|
|
111
|
+
trials = new Map();
|
|
110
112
|
working = new Set();
|
|
111
113
|
/** What is being worked out, innermost last, for reporting a circle. */
|
|
112
114
|
trail = [];
|
|
@@ -175,6 +177,8 @@ export class Placer {
|
|
|
175
177
|
/** Work out everything, and write it onto the nodes. */
|
|
176
178
|
run() {
|
|
177
179
|
this.checkAnchors(this.roots, undefined);
|
|
180
|
+
for (const node of this.byName.values())
|
|
181
|
+
this.checkLimitTargets(node);
|
|
178
182
|
for (const node of this.byName.values()) {
|
|
179
183
|
for (const axis of BOTH_AXES) {
|
|
180
184
|
this.size(node, axis);
|
|
@@ -189,9 +193,76 @@ export class Placer {
|
|
|
189
193
|
}
|
|
190
194
|
for (const node of this.byName.values())
|
|
191
195
|
this.checkYielded(node);
|
|
196
|
+
for (const node of this.byName.values())
|
|
197
|
+
this.checkLimitsMet(node);
|
|
192
198
|
for (const node of this.byName.values())
|
|
193
199
|
this.finish(node);
|
|
194
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* What a limit may relate (design.org 5.6): two things in one box that
|
|
203
|
+
* nothing else ties together, since the box's own spacing between them is
|
|
204
|
+
* the only distance it may lengthen.
|
|
205
|
+
*/
|
|
206
|
+
checkLimitTargets(node) {
|
|
207
|
+
for (const limit of node.limits ?? []) {
|
|
208
|
+
const parent = node.parent;
|
|
209
|
+
const said = `"${node.name} ${describeLimit(limit)}"`;
|
|
210
|
+
const named = this.byName.get(limit.target.name);
|
|
211
|
+
if (!named) {
|
|
212
|
+
throw new SourceError(`${said}: "${limit.target.name}" does not exist${partWordHint(limit.target.name, this.byName.keys())}`, limit.line);
|
|
213
|
+
}
|
|
214
|
+
const ownText = parent !== undefined && named === parent && limit.target.part === 'text';
|
|
215
|
+
const sibling = parent !== undefined && named.parent === parent && named !== node && limit.target.part === undefined;
|
|
216
|
+
if (!parent || !(ownText || sibling)) {
|
|
217
|
+
throw new SourceError(`${said}: a limit keeps clear of something in the same box, a sibling or ` +
|
|
218
|
+
`${parent ? `"${parent.name} text"` : 'the box\'s own text'}, since that box is what makes room`, limit.line);
|
|
219
|
+
}
|
|
220
|
+
if (ownText && !this.hasTitle(parent)) {
|
|
221
|
+
throw new SourceError(`${said}: "${parent.name}" has no text`, limit.line);
|
|
222
|
+
}
|
|
223
|
+
const axis = limitAxis(limit);
|
|
224
|
+
const own = this.groupOf(node, parent, axis);
|
|
225
|
+
if (own !== 'frame' && own === this.groupOf(ownText ? 'text' : named, parent, axis)) {
|
|
226
|
+
const how = own === 'title' ? `with "${parent.name}"'s text` : 'against each other';
|
|
227
|
+
throw new SourceError(`${said} could never change anything: "${node.name}" and "${nameTarget(limit.target)}" are placed ${how}, ` +
|
|
228
|
+
'so the distance between them is already stated — a limit relates two things in one box that nothing else ties together, ' +
|
|
229
|
+
`such as a child placed against "${parent.name}"'s side and one of its other children`, limit.line);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Which of the things a box holds that nothing ties to each other a member
|
|
235
|
+
* belongs to, on one axis: its title, what is placed against the box's
|
|
236
|
+
* sides, or the rest, placed against each other.
|
|
237
|
+
*/
|
|
238
|
+
groupOf(member, parent, axis) {
|
|
239
|
+
const titleTied = this.anchoredOnTitle(parent) || (parent.titlePlacements?.length ?? 0) > 0;
|
|
240
|
+
if (member === 'text' || member === parent.heading || joinedToText(member))
|
|
241
|
+
return titleTied ? 'content' : 'title';
|
|
242
|
+
return this.framedOn(member, axis) ? 'frame' : 'content';
|
|
243
|
+
}
|
|
244
|
+
/** A limit the box couldn't make room for: no side helps, or a stated size holds it in. */
|
|
245
|
+
checkLimitsMet(node) {
|
|
246
|
+
for (const limit of node.limits ?? []) {
|
|
247
|
+
const parent = node.parent;
|
|
248
|
+
const axis = limitAxis(limit);
|
|
249
|
+
const frame = this.frame(parent, axis);
|
|
250
|
+
if (this.shortfall(parent, axis, { subject: node, limit }, frame) <= LIMIT_EPSILON)
|
|
251
|
+
continue;
|
|
252
|
+
const said = `"${describeLimit(limit)}"`;
|
|
253
|
+
if (frame.boxSize !== this.outline(parent, axis).boxSize) {
|
|
254
|
+
throw new SourceError(`"${node.name} ${describeLimit(limit)}", but "${parent.name}"'s stated ${axis === 'x' ? 'width' : 'height'} leaves no room for it — ` +
|
|
255
|
+
'make it bigger, or leave the size unstated so the box can make room', limit.line);
|
|
256
|
+
}
|
|
257
|
+
const named = this.byName.get(limit.target.name);
|
|
258
|
+
const framed = [node, named].find((member) => member !== parent && this.framedOn(member, axis));
|
|
259
|
+
const where = framed
|
|
260
|
+
? this.statements(framed, axis).map((statement) => `"${framed.name} ${describePlacement(statement.placement)}"`).join(' and ')
|
|
261
|
+
: undefined;
|
|
262
|
+
throw new SourceError(`"${node.name}" can't keep to ${said}${where ? ` with ${where}` : ''}: "${parent.name}" holds both, and growing it ` +
|
|
263
|
+
'never moves them apart — keep one of the two', limit.line);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
195
266
|
// --- the quantities ------------------------------------------------------
|
|
196
267
|
/** Where a node starts on the page. */
|
|
197
268
|
page(node, axis) {
|
|
@@ -363,6 +434,9 @@ export class Placer {
|
|
|
363
434
|
/** A container's box and title in its own frame, at the size it ends up. */
|
|
364
435
|
frame(node, axis) {
|
|
365
436
|
const key = `${node.name}\n${axis}`;
|
|
437
|
+
const trial = this.trials.get(key);
|
|
438
|
+
if (trial)
|
|
439
|
+
return trial;
|
|
366
440
|
const known = this.frames.get(key);
|
|
367
441
|
if (known)
|
|
368
442
|
return known;
|
|
@@ -376,10 +450,91 @@ export class Placer {
|
|
|
376
450
|
const known = this.outlines.get(key);
|
|
377
451
|
if (known)
|
|
378
452
|
return known;
|
|
379
|
-
const worked = this.guard(`outline\n${key}`, node, axis, undefined, () => this.
|
|
453
|
+
const worked = this.guard(`outline\n${key}`, node, axis, undefined, () => this.roomFor(node, axis));
|
|
380
454
|
this.outlines.set(key, worked);
|
|
381
455
|
return worked;
|
|
382
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* A container's outline with room made for its children's limits
|
|
459
|
+
* (design.org 5.6). The only distance a limit may lengthen is between what
|
|
460
|
+
* the box holds and its sides, which every placement inside leaves unstated.
|
|
461
|
+
* Each side's room moves a limit's two ends apart at a fixed rate (all of
|
|
462
|
+
* it for a corner, half for the middle of a side), so one trial of each
|
|
463
|
+
* side gives the rate, and the room is the shortfall over the rate. A side
|
|
464
|
+
* that doesn't help is never used; with both helping, as for two corner
|
|
465
|
+
* children, the box grows evenly. A limit no side helps is left unmet, and
|
|
466
|
+
* `checkLimits` refuses it.
|
|
467
|
+
*/
|
|
468
|
+
roomFor(node, axis) {
|
|
469
|
+
const limits = this.limitsIn(node, axis);
|
|
470
|
+
let room = NO_ROOM;
|
|
471
|
+
let frame = this.workOutFrame(node, axis);
|
|
472
|
+
for (let round = 0; round < limits.length * 2; round++) {
|
|
473
|
+
const short = (at) => Math.max(...limits.map((each) => this.shortfall(node, axis, each, at)));
|
|
474
|
+
const owed = short(frame);
|
|
475
|
+
if (owed <= LIMIT_EPSILON)
|
|
476
|
+
break;
|
|
477
|
+
const rate = (more) => (owed - short(this.workOutFrame(node, axis, more))) / owed;
|
|
478
|
+
const low = rate({ low: room.low + owed, high: room.high });
|
|
479
|
+
const high = rate({ low: room.low, high: room.high + owed });
|
|
480
|
+
if (low > LIMIT_EPSILON && high > LIMIT_EPSILON) {
|
|
481
|
+
const both = rate({ low: room.low + owed, high: room.high + owed });
|
|
482
|
+
room = { low: room.low + owed / both, high: room.high + owed / both };
|
|
483
|
+
}
|
|
484
|
+
else if (low > LIMIT_EPSILON) {
|
|
485
|
+
room = { ...room, low: room.low + owed / low };
|
|
486
|
+
}
|
|
487
|
+
else if (high > LIMIT_EPSILON) {
|
|
488
|
+
room = { ...room, high: room.high + owed / high };
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
frame = this.workOutFrame(node, axis, room);
|
|
494
|
+
}
|
|
495
|
+
return frame;
|
|
496
|
+
}
|
|
497
|
+
/** The limits a container's children state on one axis. */
|
|
498
|
+
limitsIn(node, axis) {
|
|
499
|
+
return node.children.flatMap((subject) => (subject.limits ?? []).filter((limit) => limitAxis(limit) === axis).map((limit) => ({ subject, limit })));
|
|
500
|
+
}
|
|
501
|
+
/** How much nearer than its gap a limit's two ends are, in a trial frame of their container. */
|
|
502
|
+
shortfall(node, axis, { subject, limit }, at) {
|
|
503
|
+
const own = this.memberAt(subject, node, axis, at);
|
|
504
|
+
const other = limit.target.name === node.name ? { start: at.textStart, size: at.textSize } : this.memberAt(this.byName.get(limit.target.name), node, axis, at);
|
|
505
|
+
const gap = this.limitGap(subject, limit);
|
|
506
|
+
const toward = limit.way === 'above' || limit.way === 'left';
|
|
507
|
+
return toward ? own.start + own.size + gap - other.start : other.start + other.size + gap - own.start;
|
|
508
|
+
}
|
|
509
|
+
/** The distance a limit keeps: its gap, or the usual one; beside a text, as beside any text. */
|
|
510
|
+
limitGap(subject, limit) {
|
|
511
|
+
if (limit.gap !== undefined)
|
|
512
|
+
return gapPixels(limit.gap, `"${subject.name}"`, limit.line);
|
|
513
|
+
const parent = subject.parent;
|
|
514
|
+
if (limit.target.name === parent.name)
|
|
515
|
+
return this.hasTitle(parent) ? UNIT : 0;
|
|
516
|
+
return GAPS['normal'];
|
|
517
|
+
}
|
|
518
|
+
/** Where a child of `node` is on one axis, in a trial frame of `node`. */
|
|
519
|
+
memberAt(member, node, axis, at) {
|
|
520
|
+
const size = this.size(member, axis);
|
|
521
|
+
if (member === node.heading)
|
|
522
|
+
return { start: at.titleStart, size };
|
|
523
|
+
if (joinedToText(member))
|
|
524
|
+
return { start: at.textStart + this.unitStart(member, axis), size };
|
|
525
|
+
if (!this.framedOn(member, axis))
|
|
526
|
+
return { start: this.start(member, axis), size };
|
|
527
|
+
// Placed against the box: where the trial box puts it, worked out
|
|
528
|
+
// without remembering, since the box isn't final.
|
|
529
|
+
const key = `${node.name}\n${axis}`;
|
|
530
|
+
this.trials.set(key, at);
|
|
531
|
+
try {
|
|
532
|
+
return { start: this.placed(member, axis), size };
|
|
533
|
+
}
|
|
534
|
+
finally {
|
|
535
|
+
this.trials.delete(key);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
383
538
|
remember(memo, node, axis, work) {
|
|
384
539
|
const key = `${node.name}\n${axis}`;
|
|
385
540
|
const known = memo.get(key);
|
|
@@ -480,6 +635,7 @@ export class Placer {
|
|
|
480
635
|
}
|
|
481
636
|
const [first, second] = statements;
|
|
482
637
|
if (first.kind !== 'align' && second.kind !== 'align' && first.kind !== second.kind) {
|
|
638
|
+
this.refuseBoxAndSibling(node, axis, first.placement, second.placement);
|
|
483
639
|
// Opposite placements: centered between the two at its own size. Each
|
|
484
640
|
// target stays where its own statement put it.
|
|
485
641
|
const after = first.kind === 'after' ? first : second;
|
|
@@ -494,6 +650,36 @@ export class Placer {
|
|
|
494
650
|
return pins.start;
|
|
495
651
|
throw this.overStated(node, axis, statements);
|
|
496
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* Opposite placements center a box between two others, but not when one of
|
|
655
|
+
* them is its own container's box: `inside c top-right right of c.k` would
|
|
656
|
+
* be neither in the corner nor a gap from `c.k` (design.org 5.3). Refused,
|
|
657
|
+
* naming the two ways out: the corner's other half beside the sibling, or
|
|
658
|
+
* the corner kept and the sibling held off with `stays`.
|
|
659
|
+
*/
|
|
660
|
+
refuseBoxAndSibling(node, axis, first, second) {
|
|
661
|
+
const parent = node.parent;
|
|
662
|
+
if (!parent || this.againstBox(first, parent) === this.againstBox(second, parent))
|
|
663
|
+
return;
|
|
664
|
+
const [box, other] = this.againstBox(first, parent) ? [first, second] : [second, first];
|
|
665
|
+
if (other.kind !== 'offset')
|
|
666
|
+
return;
|
|
667
|
+
const ways = [];
|
|
668
|
+
const corner = box.targets[0]?.part;
|
|
669
|
+
if (box.kind === 'offset' && box.written !== undefined && corner !== undefined && corner.includes('-')) {
|
|
670
|
+
const [down, across] = corner.split('-');
|
|
671
|
+
const kept = axis === 'x' ? down : across;
|
|
672
|
+
ways.push(`to sit ${describePlacement(other)} at the ${kept} of ${parent.name}, write "${describePlacement(other)} ${box.written} ${parent.name} ${kept}"`);
|
|
673
|
+
}
|
|
674
|
+
const way = component(other.direction, axis);
|
|
675
|
+
const straight = axis === 'x' ? (way === 'after' ? 'right' : 'left') : way === 'after' ? 'below' : 'above';
|
|
676
|
+
const of = axis === 'x' ? ' of' : '';
|
|
677
|
+
if (other.targets.length === 1) {
|
|
678
|
+
ways.push(`to stay ${describePlacement(box)} and clear of ${nameTarget(other.targets[0])}, write "stays ${straight}${of} ${nameTarget(other.targets[0])}"`);
|
|
679
|
+
}
|
|
680
|
+
throw new SourceError(`"${node.name}" says twice where it goes ${AXIS_SAID[axis]}, with "${describePlacement(box)}" and "${describePlacement(other)}"` +
|
|
681
|
+
(ways.length > 0 ? `; ${ways.join('; ')}` : ' — keep one'), other.line);
|
|
682
|
+
}
|
|
497
683
|
edgeOf(node, axis, statement) {
|
|
498
684
|
return this.guard(`edge\n${node.name}\n${axis}\n${statement.placement.line}`, node, axis, statement.placement, () => statement.kind === 'align' ? 0 : statement.edge());
|
|
499
685
|
}
|
|
@@ -991,7 +1177,7 @@ export class Placer {
|
|
|
991
1177
|
text = { start: frame.textStart, size: frame.textSize };
|
|
992
1178
|
}
|
|
993
1179
|
else if (named.parent === parent) {
|
|
994
|
-
if (this.
|
|
1180
|
+
if (this.framedOn(named, axis) && !this.framedOn(node, axis)) {
|
|
995
1181
|
throw new SourceError(`"${node.name}" is placed against "${named.name}", which is placed against "${parent.name}" itself. ` +
|
|
996
1182
|
`Nothing may be placed against a thing placed against its container's side or corner — place ` +
|
|
997
1183
|
`"${node.name}" against another child`, placement.line);
|
|
@@ -1049,7 +1235,27 @@ export class Placer {
|
|
|
1049
1235
|
const parent = node.parent;
|
|
1050
1236
|
if (!parent || this.facts.get(parent).layout || this.facts.get(parent).venn)
|
|
1051
1237
|
return false;
|
|
1052
|
-
return node.placements.some((placement) =>
|
|
1238
|
+
return node.placements.some((placement) => this.againstBox(placement, parent));
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Framed on one axis: what says where it goes on that axis is against its
|
|
1242
|
+
* container's box. `right of c.k inside c top` is framed down, so its height
|
|
1243
|
+
* is measured from the finished box, and is content across, so the box holds
|
|
1244
|
+
* it there (design.org 5.6).
|
|
1245
|
+
*/
|
|
1246
|
+
framedOn(node, axis) {
|
|
1247
|
+
if (!this.framed(node))
|
|
1248
|
+
return false;
|
|
1249
|
+
const parent = node.parent;
|
|
1250
|
+
const statements = this.statements(node, axis);
|
|
1251
|
+
if (statements.length > 0)
|
|
1252
|
+
return statements.some((statement) => this.againstBox(statement.placement, parent));
|
|
1253
|
+
// Nothing states this axis, so a lone direction on the other one centers it on its target.
|
|
1254
|
+
return node.placements.some((placement) => placement.kind === 'offset' && component(placement.direction, axis) === undefined && this.againstBox(placement, parent));
|
|
1255
|
+
}
|
|
1256
|
+
/** Whether a placement names its container's box, any part but the text. */
|
|
1257
|
+
againstBox(placement, parent) {
|
|
1258
|
+
return placement.targets.some((target) => target.name === parent.name && target.part !== 'text');
|
|
1053
1259
|
}
|
|
1054
1260
|
/**
|
|
1055
1261
|
* A child placed against its container's text, or against its heading,
|
|
@@ -1111,10 +1317,13 @@ export class Placer {
|
|
|
1111
1317
|
/**
|
|
1112
1318
|
* The children a container's box is the outline of, besides its title: not
|
|
1113
1319
|
* its heading, not what is joined to its text, and not those placed against
|
|
1114
|
-
* the box.
|
|
1320
|
+
* the box. Given an axis, a child placed against the box only on the other
|
|
1321
|
+
* axis is content on this one.
|
|
1115
1322
|
*/
|
|
1116
|
-
contentOf(node) {
|
|
1117
|
-
return node.children.filter((child) => child !== node.heading &&
|
|
1323
|
+
contentOf(node, axis) {
|
|
1324
|
+
return node.children.filter((child) => child !== node.heading &&
|
|
1325
|
+
!joinedToText(child) &&
|
|
1326
|
+
!(axis === undefined ? this.framed(child) : this.framedOn(child, axis)));
|
|
1118
1327
|
}
|
|
1119
1328
|
/** Whether a node has a title: its text, what is joined to it, or a heading. */
|
|
1120
1329
|
hasTitle(node) {
|
|
@@ -1307,12 +1516,12 @@ export class Placer {
|
|
|
1307
1516
|
* not placed against the box itself, and the title when it sits inside the
|
|
1308
1517
|
* border or on it.
|
|
1309
1518
|
*/
|
|
1310
|
-
workOutFrame(node, axis) {
|
|
1519
|
+
workOutFrame(node, axis, room = NO_ROOM) {
|
|
1311
1520
|
const facts = this.facts.get(node);
|
|
1312
1521
|
const title = this.titleOf(node);
|
|
1313
1522
|
const hasTitle = this.hasTitle(node);
|
|
1314
1523
|
const titleSize = this.titleSize(node, axis);
|
|
1315
|
-
const content = this.contentOf(node);
|
|
1524
|
+
const content = this.contentOf(node, axis);
|
|
1316
1525
|
const anchoredOnTitle = this.anchoredOnTitle(node);
|
|
1317
1526
|
let low = Infinity;
|
|
1318
1527
|
let high = -Infinity;
|
|
@@ -1343,6 +1552,12 @@ export class Placer {
|
|
|
1343
1552
|
high = Math.max(high, band);
|
|
1344
1553
|
}
|
|
1345
1554
|
}
|
|
1555
|
+
// Room a child's limit asks for (design.org 5.6), between what the box
|
|
1556
|
+
// holds and its sides, so a title against a side moves out with it.
|
|
1557
|
+
if (!empty) {
|
|
1558
|
+
low -= room.low;
|
|
1559
|
+
high += room.high;
|
|
1560
|
+
}
|
|
1346
1561
|
// Where the title sits in the frame. When something is placed against it,
|
|
1347
1562
|
// it is the frame's anchor and sits at the origin; otherwise it is placed
|
|
1348
1563
|
// against the children by its side.
|
|
@@ -1358,6 +1573,10 @@ export class Placer {
|
|
|
1358
1573
|
low = titleStart;
|
|
1359
1574
|
high = titleStart + titleSize;
|
|
1360
1575
|
}
|
|
1576
|
+
if (empty) {
|
|
1577
|
+
low -= room.low;
|
|
1578
|
+
high += room.high;
|
|
1579
|
+
}
|
|
1361
1580
|
}
|
|
1362
1581
|
else if (title.how === 'center' || title.side === 'center') {
|
|
1363
1582
|
titleStart = (low + high) / 2 - titleSize / 2;
|
|
@@ -1590,7 +1809,7 @@ export class Placer {
|
|
|
1590
1809
|
innerOther(node, axis) {
|
|
1591
1810
|
const key = `inner\n${node.name}\n${axis}`;
|
|
1592
1811
|
return this.guard(key, node, axis, undefined, () => {
|
|
1593
|
-
const content = this.contentOf(node);
|
|
1812
|
+
const content = this.contentOf(node, axis);
|
|
1594
1813
|
let low = Infinity;
|
|
1595
1814
|
let high = -Infinity;
|
|
1596
1815
|
for (const child of content) {
|
|
@@ -2173,6 +2392,13 @@ export class Placer {
|
|
|
2173
2392
|
return { low, high };
|
|
2174
2393
|
}
|
|
2175
2394
|
}
|
|
2395
|
+
const NO_ROOM = { low: 0, high: 0 };
|
|
2396
|
+
/** Less than this is no distance at all, for a limit worked out in floating point. */
|
|
2397
|
+
const LIMIT_EPSILON = 0.01;
|
|
2398
|
+
/** The axis a limit keeps to: up and down for above and below. */
|
|
2399
|
+
function limitAxis(limit) {
|
|
2400
|
+
return limit.way === 'above' || limit.way === 'below' ? 'y' : 'x';
|
|
2401
|
+
}
|
|
2176
2402
|
/**
|
|
2177
2403
|
* `top even with 2 below design top` on a venn's set: the other set's same
|
|
2178
2404
|
* side, moved up or down. Anything else, such as its bottom or a sideways
|
package/dist/resolve.js
CHANGED
|
@@ -771,6 +771,7 @@ function buildTree(statements, styles, defaults, icons) {
|
|
|
771
771
|
attrs: stmt.attrs,
|
|
772
772
|
appearance,
|
|
773
773
|
placements: stmt.placements,
|
|
774
|
+
...(stmt.limits ? { limits: stmt.limits } : {}),
|
|
774
775
|
...(stmt.guide ? { guide: true } : {}),
|
|
775
776
|
line: stmt.line,
|
|
776
777
|
};
|
|
@@ -1700,6 +1701,7 @@ function lineLook(written, appearance, what, line) {
|
|
|
1700
1701
|
turn,
|
|
1701
1702
|
turnWritten: written['line.turn'],
|
|
1702
1703
|
stepOut,
|
|
1704
|
+
stepOutWritten: written['line.step-out'],
|
|
1703
1705
|
};
|
|
1704
1706
|
// Said on the edge itself, a property its path doesn't have is refused; from
|
|
1705
1707
|
// a style or a default it lands on the lines that have it.
|