reladraw 0.7.1 → 0.8.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/SYNTAX.md +10 -1
- package/dist/icons.d.ts +7 -4
- package/dist/icons.js +6 -3
- package/dist/render.js +53 -0
- package/dist/resolve.js +83 -7
- package/package.json +1 -1
package/SYNTAX.md
CHANGED
|
@@ -171,6 +171,7 @@ Every node has a *body*: the thing that is drawn where the node is. Two keys nam
|
|
|
171
171
|
| --- | --- |
|
|
172
172
|
| `rectangle` | The plain rounded box. The default, so nothing has to say it. |
|
|
173
173
|
| `document` | The same box with its top-right corner folded — the flowchart symbol saying *this is an artifact, not a process*. |
|
|
174
|
+
| `circle` | A circle just big enough to hold the text, and always round — a longer text makes a bigger circle, never an oval. |
|
|
174
175
|
| `none` | No outline, no fill, no padding. The node is its text and nothing else. |
|
|
175
176
|
|
|
176
177
|
`icon: <name>` is a picture the node is drawn **as**, with no box at all.
|
|
@@ -193,7 +194,11 @@ node svc icon: cube
|
|
|
193
194
|
|
|
194
195
|
A node has one body, so writing both keys is an error naming both.
|
|
195
196
|
|
|
196
|
-
The `document` fold is worth having because a shape is a second channel alongside color, and a stronger one. A fill means whatever you assigned it and a reader has to learn it from the diagram; a folded corner has meant "a document" for as long as there have been flowcharts, and reads with no legend. Most diagrams lose the difference between a thing that runs and a thing that is produced, because every node is a rectangle. `
|
|
197
|
+
The `document` fold is worth having because a shape is a second channel alongside color, and a stronger one. A fill means whatever you assigned it and a reader has to learn it from the diagram; a folded corner has meant "a document" for as long as there have been flowcharts, and reads with no legend. Most diagrams lose the difference between a thing that runs and a thing that is produced, because every node is a rectangle. `diamond` will join these when a diagram asks for one.
|
|
198
|
+
|
|
199
|
+
A circle is an ordinary node in every other way, and the file means the same thing whichever shape its nodes are. `from: right` on a circle leaves from its rightmost point, and several edges on one side spread round the quarter of the circle about that point, each meeting it square on. A corner such as `top-right` is the point on the circle halfway between those two sides, not the corner of the square around it. A circle can hold other nodes; they sit in the middle of it. A circle cannot yet take a `badge:`, a `deck:`, or a node placed against its own edge or text, and each is refused with an error saying so.
|
|
200
|
+
|
|
201
|
+
`circle` is the one name that describes the picture rather than the meaning, because a circle has no single meaning to name it after: it is a state in one diagram, a step in another, a person in a third.
|
|
197
202
|
|
|
198
203
|
A name says what the thing *is*, never what the picture looks like: naming the meaning is what lets the drawing be improved later without every diagram that uses it changing sense. `document`, not `folded-corner`. The one place that rule stops is a picture with no single meaning — the cube stands for a container in one diagram, a VM in another, a service in a third — which is why it is called `cube` and not `instance`. An unrecognized name is an error listing the whole set, rather than a node that quietly draws nothing.
|
|
199
204
|
|
|
@@ -1012,6 +1017,10 @@ That one was found by testing the lexer, not by rendering — and it could not h
|
|
|
1012
1017
|
|
|
1013
1018
|
Pre-1.0, so the minor number is where a breaking change goes. Every removal below is refused by name with the replacement quoted, rather than dropped in silence — an older file stops with an error saying what to write instead.
|
|
1014
1019
|
|
|
1020
|
+
**Unreleased**
|
|
1021
|
+
|
|
1022
|
+
- `shape: circle`. A circle is sized to hold its text and is always round. Edges on one of its sides spread round that quarter of the circle, and a corner of it is the point on the circle halfway round. It does not yet take a badge, a deck or a node placed against its own edge. Nothing that already renders changes.
|
|
1023
|
+
|
|
1015
1024
|
**0.7.1**
|
|
1016
1025
|
|
|
1017
1026
|
- A fix, no syntax change. An edge with `line: (path: straight)` and a clause such as `below m` could draw its text on top of `m`. The line now bends through the room left for the text, so the text sits clear of the node. Existing diagrams with such an edge will look different, and correct.
|
package/dist/icons.d.ts
CHANGED
|
@@ -50,13 +50,16 @@ export declare const ICON_NAMES: string[];
|
|
|
50
50
|
* diagram, while a folded corner has meant "an artifact, not a process" in
|
|
51
51
|
* flowcharts for decades and reads with no legend at all.
|
|
52
52
|
*
|
|
53
|
-
* `circle`
|
|
54
|
-
*
|
|
53
|
+
* `circle` is the one geometric name, because a circle carries no single
|
|
54
|
+
* meaning to name it after — it is a state, a step, a person, whatever the
|
|
55
|
+
* diagram's own convention makes it. `diamond` joins these when a diagram
|
|
56
|
+
* asks. The set being short is a fact about what has been drawn, not about the
|
|
57
|
+
* key.
|
|
55
58
|
*/
|
|
56
|
-
export declare const OUTLINES: readonly ["rectangle", "document"];
|
|
59
|
+
export declare const OUTLINES: readonly ["rectangle", "document", "circle"];
|
|
57
60
|
export type Outline = (typeof OUTLINES)[number];
|
|
58
61
|
/** Every value `shape:` accepts, `none` included. */
|
|
59
|
-
export declare const SHAPE_WORDS: readonly ["rectangle", "document", "none"];
|
|
62
|
+
export declare const SHAPE_WORDS: readonly ["rectangle", "document", "circle", "none"];
|
|
60
63
|
/**
|
|
61
64
|
* What a node is drawn as: an outline, a picture, or nothing at all.
|
|
62
65
|
*
|
package/dist/icons.js
CHANGED
|
@@ -126,10 +126,13 @@ export const ICON_NAMES = Object.keys(ICONS);
|
|
|
126
126
|
* diagram, while a folded corner has meant "an artifact, not a process" in
|
|
127
127
|
* flowcharts for decades and reads with no legend at all.
|
|
128
128
|
*
|
|
129
|
-
* `circle`
|
|
130
|
-
*
|
|
129
|
+
* `circle` is the one geometric name, because a circle carries no single
|
|
130
|
+
* meaning to name it after — it is a state, a step, a person, whatever the
|
|
131
|
+
* diagram's own convention makes it. `diamond` joins these when a diagram
|
|
132
|
+
* asks. The set being short is a fact about what has been drawn, not about the
|
|
133
|
+
* key.
|
|
131
134
|
*/
|
|
132
|
-
export const OUTLINES = ['rectangle', 'document'];
|
|
135
|
+
export const OUTLINES = ['rectangle', 'document', 'circle'];
|
|
133
136
|
/** Every value `shape:` accepts, `none` included. */
|
|
134
137
|
export const SHAPE_WORDS = [...OUTLINES, 'none'];
|
|
135
138
|
const PLAIN = { kind: 'shape', outline: 'rectangle' };
|
package/dist/render.js
CHANGED
|
@@ -230,6 +230,16 @@ const FOLD = CORNER * 2;
|
|
|
230
230
|
/** The node's outline, as path data. */
|
|
231
231
|
function outlinePath(shape, x, y, w, h) {
|
|
232
232
|
const r = CORNER;
|
|
233
|
+
if (shape === 'circle') {
|
|
234
|
+
// Two half-turns from the leftmost point, since one arc cannot close.
|
|
235
|
+
const radius = w / 2;
|
|
236
|
+
return [
|
|
237
|
+
`M${round(x)} ${round(y + h / 2)}`,
|
|
238
|
+
`a${round(radius)} ${round(radius)} 0 1 0 ${round(w)} 0`,
|
|
239
|
+
`a${round(radius)} ${round(radius)} 0 1 0 ${round(-w)} 0`,
|
|
240
|
+
'Z',
|
|
241
|
+
].join(' ');
|
|
242
|
+
}
|
|
233
243
|
if (shape === 'document') {
|
|
234
244
|
// Every corner rounded but the top-right one, which is cut away and folded.
|
|
235
245
|
return [
|
|
@@ -897,6 +907,10 @@ function sidePoint(box, toward) {
|
|
|
897
907
|
const dy = toward.y - center.y;
|
|
898
908
|
if (dx === 0 && dy === 0)
|
|
899
909
|
return center;
|
|
910
|
+
if (box.round) {
|
|
911
|
+
const scale = box.width / 2 / Math.hypot(dx, dy);
|
|
912
|
+
return { x: center.x + dx * scale, y: center.y + dy * scale };
|
|
913
|
+
}
|
|
900
914
|
const scaleX = dx === 0 ? Infinity : box.width / 2 / Math.abs(dx);
|
|
901
915
|
const scaleY = dy === 0 ? Infinity : box.height / 2 / Math.abs(dy);
|
|
902
916
|
const scale = Math.min(scaleX, scaleY);
|
|
@@ -1291,6 +1305,8 @@ function claim(claims, node, side, entry) {
|
|
|
1291
1305
|
}
|
|
1292
1306
|
/** The point `at` along one side of a box, with the outward normal for that side. */
|
|
1293
1307
|
function anchorOn(face, side, at) {
|
|
1308
|
+
if (face.round)
|
|
1309
|
+
return anchorOnCircle(face, side, at);
|
|
1294
1310
|
switch (side) {
|
|
1295
1311
|
case 'top':
|
|
1296
1312
|
return { x: at, y: face.y, tx: 0, ty: -1, side };
|
|
@@ -1302,6 +1318,28 @@ function anchorOn(face, side, at) {
|
|
|
1302
1318
|
return { x: face.x + face.width, y: at, tx: 1, ty: 0, side };
|
|
1303
1319
|
}
|
|
1304
1320
|
}
|
|
1321
|
+
/**
|
|
1322
|
+
* The same, on a circle: a side is the quarter of the circle around its
|
|
1323
|
+
* compass point, and `at` is walked round the arc rather than along a
|
|
1324
|
+
* straight edge, so points spaced a step apart on a side are a step apart on
|
|
1325
|
+
* the circle too. The line meets the circle square on, heading from its center.
|
|
1326
|
+
*/
|
|
1327
|
+
function anchorOnCircle(face, side, at) {
|
|
1328
|
+
const radius = face.width / 2;
|
|
1329
|
+
const center = centerOf(face);
|
|
1330
|
+
const across = side === 'top' || side === 'bottom' ? at - center.x : at - center.y;
|
|
1331
|
+
const turn = Math.max(-Math.PI / 4, Math.min(Math.PI / 4, across / radius));
|
|
1332
|
+
// SVG's y runs down, so the bottom is a quarter-turn clockwise from the right.
|
|
1333
|
+
const angle = {
|
|
1334
|
+
right: turn,
|
|
1335
|
+
bottom: Math.PI / 2 - turn,
|
|
1336
|
+
left: Math.PI - turn,
|
|
1337
|
+
top: -Math.PI / 2 + turn,
|
|
1338
|
+
}[side];
|
|
1339
|
+
const tx = Math.cos(angle);
|
|
1340
|
+
const ty = Math.sin(angle);
|
|
1341
|
+
return { x: center.x + radius * tx, y: center.y + radius * ty, tx, ty, side };
|
|
1342
|
+
}
|
|
1305
1343
|
/** An end with no side named: leave from the border, pointing at the far end. */
|
|
1306
1344
|
function free(face, toward) {
|
|
1307
1345
|
const point = sidePoint(face, toward);
|
|
@@ -1324,6 +1362,20 @@ function exitAlong(box, from, dir) {
|
|
|
1324
1362
|
// is the graceful answer, and the crowding it signals is a diagnostic.
|
|
1325
1363
|
const x = Math.min(Math.max(from.x, box.x), box.x + box.width);
|
|
1326
1364
|
const y = Math.min(Math.max(from.y, box.y), box.y + box.height);
|
|
1365
|
+
if (box.round) {
|
|
1366
|
+
// Where the ray leaves the circle: the larger root of |p + t·dir − c| = r.
|
|
1367
|
+
const center = centerOf(box);
|
|
1368
|
+
const px = x - center.x;
|
|
1369
|
+
const py = y - center.y;
|
|
1370
|
+
const a = dir.x * dir.x + dir.y * dir.y;
|
|
1371
|
+
const b = px * dir.x + py * dir.y;
|
|
1372
|
+
const c = px * px + py * py - (box.width / 2) ** 2;
|
|
1373
|
+
const reach = b * b - a * c;
|
|
1374
|
+
if (a === 0 || reach < 0)
|
|
1375
|
+
return { x, y };
|
|
1376
|
+
const t = Math.max(0, (-b + Math.sqrt(reach)) / a);
|
|
1377
|
+
return { x: x + dir.x * t, y: y + dir.y * t };
|
|
1378
|
+
}
|
|
1327
1379
|
const tx = dir.x === 0 ? Infinity : ((dir.x > 0 ? box.x + box.width : box.x) - x) / dir.x;
|
|
1328
1380
|
const ty = dir.y === 0 ? Infinity : ((dir.y > 0 ? box.y + box.height : box.y) - y) / dir.y;
|
|
1329
1381
|
const t = Math.min(tx, ty);
|
|
@@ -2421,6 +2473,7 @@ function faceOf(node) {
|
|
|
2421
2473
|
y: node.y + node.inset,
|
|
2422
2474
|
width: node.width - node.inset,
|
|
2423
2475
|
height: node.height - node.inset,
|
|
2476
|
+
round: node.body.kind === 'shape' && node.body.outline === 'circle',
|
|
2424
2477
|
};
|
|
2425
2478
|
}
|
|
2426
2479
|
function centerOf(box) {
|
package/dist/resolve.js
CHANGED
|
@@ -598,6 +598,9 @@ function sizeNode(node, edges, measurer, fontSize, local) {
|
|
|
598
598
|
// too. The *absence* of children makes the setting inert, which stays silent;
|
|
599
599
|
// a value the language does not have is wrong wherever it is written.
|
|
600
600
|
const contents = contentsStyleFor(node);
|
|
601
|
+
const circle = isCircle(node);
|
|
602
|
+
if (circle)
|
|
603
|
+
refuseOnCircle(node);
|
|
601
604
|
if (node.children.length === 0) {
|
|
602
605
|
// `at` is read on a leaf too, and is inert wherever the box is exactly the
|
|
603
606
|
// size of what it holds — which is every leaf, since a leaf is sized from
|
|
@@ -629,7 +632,7 @@ function sizeNode(node, edges, measurer, fontSize, local) {
|
|
|
629
632
|
}
|
|
630
633
|
else {
|
|
631
634
|
if (contents.widths === 'match')
|
|
632
|
-
matchWidths(node.children);
|
|
635
|
+
matchWidths(node.children, local);
|
|
633
636
|
let content = layoutChildren(node.children, edges, measurer, fontSize, local);
|
|
634
637
|
if (contents.widths === 'fill') {
|
|
635
638
|
// The band is the wider of the title and the contents, so filling it
|
|
@@ -638,7 +641,7 @@ function sizeNode(node, edges, measurer, fontSize, local) {
|
|
|
638
641
|
// could not give.
|
|
639
642
|
const band = Math.max(textWidth, content.width);
|
|
640
643
|
for (const child of node.children)
|
|
641
|
-
widenTo(child, band);
|
|
644
|
+
widenTo(child, band, local);
|
|
642
645
|
content = layoutChildren(node.children, edges, measurer, fontSize, local);
|
|
643
646
|
checkOneColumn(node, node.children, local);
|
|
644
647
|
}
|
|
@@ -671,8 +674,62 @@ function sizeNode(node, edges, measurer, fontSize, local) {
|
|
|
671
674
|
}
|
|
672
675
|
node.banded = true;
|
|
673
676
|
}
|
|
677
|
+
if (circle)
|
|
678
|
+
circumscribe(node, local);
|
|
674
679
|
applyDeck(node, local);
|
|
675
680
|
}
|
|
681
|
+
function isCircle(node) {
|
|
682
|
+
return node.body.kind === 'shape' && node.body.outline === 'circle';
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Grow a node sized as a rectangle into the circle around what it holds.
|
|
686
|
+
*
|
|
687
|
+
* The circle passes a padding outside the corners of the padded-in block —
|
|
688
|
+
* the text of a leaf, or the band and contents of a container — so whatever
|
|
689
|
+
* fits in the rectangle fits in the circle, clear of its edge by the same
|
|
690
|
+
* padding at the corners where it comes closest. Everything inside moves to
|
|
691
|
+
* keep the block centered.
|
|
692
|
+
*/
|
|
693
|
+
function circumscribe(node, local) {
|
|
694
|
+
const side = Math.hypot(node.width - PAD * 2, node.height - PAD * 2) + PAD * 2;
|
|
695
|
+
moveInside(node, (side - node.width) / 2, (side - node.height) / 2, local);
|
|
696
|
+
node.width = side;
|
|
697
|
+
node.height = side;
|
|
698
|
+
}
|
|
699
|
+
function moveInside(node, dx, dy, local) {
|
|
700
|
+
node.textBox.x += dx;
|
|
701
|
+
node.textBox.y += dy;
|
|
702
|
+
for (const child of node.children) {
|
|
703
|
+
const offset = local.get(child);
|
|
704
|
+
offset.x += dx;
|
|
705
|
+
offset.y += dy;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* What a circle does not take yet, refused by name rather than drawn as an
|
|
710
|
+
* ellipse or with a copy's text outside the outline.
|
|
711
|
+
*
|
|
712
|
+
* Something placed against the circle's own edge or text — a badge included —
|
|
713
|
+
* sizes the frame in the same solve as the thing placed, and a circle's corner
|
|
714
|
+
* points are a fraction of a radius that solve does not know yet. A deck's
|
|
715
|
+
* copies write their text at the top-left of the face, which on a circle is
|
|
716
|
+
* outside it.
|
|
717
|
+
*/
|
|
718
|
+
function refuseOnCircle(node) {
|
|
719
|
+
const [framed] = framedChildren(node);
|
|
720
|
+
if (node.attrs['badge'] !== undefined || node.appearance['badge'] !== undefined) {
|
|
721
|
+
throw new SourceError(`"${node.name}" is a circle with a badge:, and a badge is not built for circles yet — ` +
|
|
722
|
+
'make it a rectangle, or place the badge as a node of its own against the circle', node.line);
|
|
723
|
+
}
|
|
724
|
+
if (framed !== undefined) {
|
|
725
|
+
throw new SourceError(`"${framed.name}" is placed against "${node.name}" itself, and "${node.name}" is a circle. ` +
|
|
726
|
+
'Something placed against a circle\'s own edge or text is not built yet — place it against ' +
|
|
727
|
+
`another node, or make "${node.name}" a rectangle`, framed.line);
|
|
728
|
+
}
|
|
729
|
+
if (node.deckTexts.length > 0) {
|
|
730
|
+
throw new SourceError(`"${node.name}" is a circle with a deck:, and a deck is not built for circles yet`, node.line);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
676
733
|
/**
|
|
677
734
|
* Make room for a deck's copies, once the node's own face has been sized.
|
|
678
735
|
*
|
|
@@ -726,10 +783,10 @@ function contentsStyleFor(node) {
|
|
|
726
783
|
* before layoutChildren sizes and positions anything from those widths. A
|
|
727
784
|
* container's own children are already sized by this point.
|
|
728
785
|
*/
|
|
729
|
-
function matchWidths(children) {
|
|
786
|
+
function matchWidths(children, local) {
|
|
730
787
|
const maxWidth = Math.max(...children.map((child) => child.width));
|
|
731
788
|
for (const child of children)
|
|
732
|
-
widenTo(child, maxWidth);
|
|
789
|
+
widenTo(child, maxWidth, local);
|
|
733
790
|
}
|
|
734
791
|
/**
|
|
735
792
|
* `widths: fill` makes every child as wide as the band, which only holds for
|
|
@@ -757,8 +814,18 @@ function checkOneColumn(node, children, local) {
|
|
|
757
814
|
* was sized from its own contents, and the text's box was worked out against
|
|
758
815
|
* the old one. A left-ranged text stays where it is; a centered or right-ranged
|
|
759
816
|
* one moves by its share of the difference.
|
|
817
|
+
*
|
|
818
|
+
* A circle grows both ways, since a wider circle is a bigger one, and what is
|
|
819
|
+
* inside it stays centered.
|
|
760
820
|
*/
|
|
761
|
-
function widenTo(node, width) {
|
|
821
|
+
function widenTo(node, width, local) {
|
|
822
|
+
if (isCircle(node)) {
|
|
823
|
+
const grew = width - node.width;
|
|
824
|
+
moveInside(node, grew / 2, grew / 2, local);
|
|
825
|
+
node.width = width;
|
|
826
|
+
node.height = width;
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
762
829
|
// A node with something placed against its own frame is laid out by a
|
|
763
830
|
// solve, and a wider frame is one more thing that solve has to hold: the
|
|
764
831
|
// things against its right edge move, and a centered text re-centers.
|
|
@@ -1013,7 +1080,7 @@ function layoutFramed(node, own, edges, measurer, fontSize, local, minWidth) {
|
|
|
1013
1080
|
let block = { width: 0, height: 0 };
|
|
1014
1081
|
if (banded) {
|
|
1015
1082
|
if (contents.widths === 'match')
|
|
1016
|
-
matchWidths(stacked);
|
|
1083
|
+
matchWidths(stacked, local);
|
|
1017
1084
|
block = layoutChildren(stacked, edges, measurer, fontSize, local);
|
|
1018
1085
|
if (contents.widths === 'fill') {
|
|
1019
1086
|
// The row is the text and whatever stands beside it, each at its gap.
|
|
@@ -1025,7 +1092,7 @@ function layoutFramed(node, own, edges, measurer, fontSize, local, minWidth) {
|
|
|
1025
1092
|
}
|
|
1026
1093
|
const across = Math.max(row, block.width);
|
|
1027
1094
|
for (const child of stacked)
|
|
1028
|
-
widenTo(child, across);
|
|
1095
|
+
widenTo(child, across, local);
|
|
1029
1096
|
block = layoutChildren(stacked, edges, measurer, fontSize, local);
|
|
1030
1097
|
checkOneColumn(node, stacked, local);
|
|
1031
1098
|
}
|
|
@@ -1720,6 +1787,15 @@ function partOf(target, part, owner, line) {
|
|
|
1720
1787
|
};
|
|
1721
1788
|
narrow('x', 'left', 'right');
|
|
1722
1789
|
narrow('y', 'top', 'bottom');
|
|
1790
|
+
// A circle has no corners, so a corner of one is the point on it halfway
|
|
1791
|
+
// between the two sides named: on the outline, as a rectangle's corner is.
|
|
1792
|
+
const across = words.includes('left') || words.includes('right');
|
|
1793
|
+
const down = words.includes('top') || words.includes('bottom');
|
|
1794
|
+
if (isCircle(target.node) && across && down) {
|
|
1795
|
+
const pull = (target.width / 2) * (1 - Math.SQRT1_2);
|
|
1796
|
+
box.offset.x += words.includes('left') ? pull : -pull;
|
|
1797
|
+
box.offset.y += words.includes('top') ? pull : -pull;
|
|
1798
|
+
}
|
|
1723
1799
|
return box;
|
|
1724
1800
|
}
|
|
1725
1801
|
/** Where a node of this size sits so that the named side of it meets the span's. */
|