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 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. `circle` and `diamond` will join these when a diagram asks for them.
197
+ The `document` fold is worth having because a shape is a second channel alongside color, and a stronger one. A fill means whatever you assigned it and a reader has to learn it from the diagram; a folded corner has meant "a document" for as long as there have been flowcharts, and reads with no legend. Most diagrams lose the difference between a thing that runs and a thing that is produced, because every node is a rectangle. `diamond` will join these when a diagram asks for one.
198
+
199
+ A circle is an ordinary node in every other way, and the file means the same thing whichever shape its nodes are. `from: right` on a circle leaves from its rightmost point, and several edges on one side spread round the quarter of the circle about that point, each meeting it square on. A corner such as `top-right` is the point on the circle halfway between those two sides, not the corner of the square around it. A circle can hold other nodes; they sit in the middle of it. A circle cannot yet take a `badge:`, a `deck:`, or a node placed against its own edge or text, and each is refused with an error saying so.
200
+
201
+ `circle` is the one name that describes the picture rather than the meaning, because a circle has no single meaning to name it after: it is a state in one diagram, a step in another, a person in a third.
197
202
 
198
203
  A name says what the thing *is*, never what the picture looks like: naming the meaning is what lets the drawing be improved later without every diagram that uses it changing sense. `document`, not `folded-corner`. The one place that rule stops is a picture with no single meaning — the cube stands for a container in one diagram, a VM in another, a service in a third — which is why it is called `cube` and not `instance`. An unrecognized name is an error listing the whole set, rather than a node that quietly draws nothing.
199
204
 
@@ -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` and `diamond` join these when a diagram asks. The set being short is
54
- * a fact about what has been drawn, not about the key.
53
+ * `circle` is the one geometric name, because a circle carries no single
54
+ * meaning to name it after — it is a state, a step, a person, whatever the
55
+ * diagram's own convention makes it. `diamond` joins these when a diagram
56
+ * asks. The set being short is a fact about what has been drawn, not about the
57
+ * key.
55
58
  */
56
- export declare const OUTLINES: readonly ["rectangle", "document"];
59
+ export declare const OUTLINES: readonly ["rectangle", "document", "circle"];
57
60
  export type Outline = (typeof OUTLINES)[number];
58
61
  /** Every value `shape:` accepts, `none` included. */
59
- export declare const SHAPE_WORDS: readonly ["rectangle", "document", "none"];
62
+ export declare const SHAPE_WORDS: readonly ["rectangle", "document", "circle", "none"];
60
63
  /**
61
64
  * What a node is drawn as: an outline, a picture, or nothing at all.
62
65
  *
package/dist/icons.js CHANGED
@@ -126,10 +126,13 @@ export const ICON_NAMES = Object.keys(ICONS);
126
126
  * diagram, while a folded corner has meant "an artifact, not a process" in
127
127
  * flowcharts for decades and reads with no legend at all.
128
128
  *
129
- * `circle` and `diamond` join these when a diagram asks. The set being short is
130
- * a fact about what has been drawn, not about the key.
129
+ * `circle` is the one geometric name, because a circle carries no single
130
+ * meaning to name it after — it is a state, a step, a person, whatever the
131
+ * diagram's own convention makes it. `diamond` joins these when a diagram
132
+ * asks. The set being short is a fact about what has been drawn, not about the
133
+ * key.
131
134
  */
132
- export const OUTLINES = ['rectangle', 'document'];
135
+ export const OUTLINES = ['rectangle', 'document', 'circle'];
133
136
  /** Every value `shape:` accepts, `none` included. */
134
137
  export const SHAPE_WORDS = [...OUTLINES, 'none'];
135
138
  const PLAIN = { kind: 'shape', outline: 'rectangle' };
package/dist/render.js CHANGED
@@ -230,6 +230,16 @@ const FOLD = CORNER * 2;
230
230
  /** The node's outline, as path data. */
231
231
  function outlinePath(shape, x, y, w, h) {
232
232
  const r = CORNER;
233
+ if (shape === 'circle') {
234
+ // Two half-turns from the leftmost point, since one arc cannot close.
235
+ const radius = w / 2;
236
+ return [
237
+ `M${round(x)} ${round(y + h / 2)}`,
238
+ `a${round(radius)} ${round(radius)} 0 1 0 ${round(w)} 0`,
239
+ `a${round(radius)} ${round(radius)} 0 1 0 ${round(-w)} 0`,
240
+ 'Z',
241
+ ].join(' ');
242
+ }
233
243
  if (shape === 'document') {
234
244
  // Every corner rounded but the top-right one, which is cut away and folded.
235
245
  return [
@@ -897,6 +907,10 @@ function sidePoint(box, toward) {
897
907
  const dy = toward.y - center.y;
898
908
  if (dx === 0 && dy === 0)
899
909
  return center;
910
+ if (box.round) {
911
+ const scale = box.width / 2 / Math.hypot(dx, dy);
912
+ return { x: center.x + dx * scale, y: center.y + dy * scale };
913
+ }
900
914
  const scaleX = dx === 0 ? Infinity : box.width / 2 / Math.abs(dx);
901
915
  const scaleY = dy === 0 ? Infinity : box.height / 2 / Math.abs(dy);
902
916
  const scale = Math.min(scaleX, scaleY);
@@ -1291,6 +1305,8 @@ function claim(claims, node, side, entry) {
1291
1305
  }
1292
1306
  /** The point `at` along one side of a box, with the outward normal for that side. */
1293
1307
  function anchorOn(face, side, at) {
1308
+ if (face.round)
1309
+ return anchorOnCircle(face, side, at);
1294
1310
  switch (side) {
1295
1311
  case 'top':
1296
1312
  return { x: at, y: face.y, tx: 0, ty: -1, side };
@@ -1302,6 +1318,28 @@ function anchorOn(face, side, at) {
1302
1318
  return { x: face.x + face.width, y: at, tx: 1, ty: 0, side };
1303
1319
  }
1304
1320
  }
1321
+ /**
1322
+ * The same, on a circle: a side is the quarter of the circle around its
1323
+ * compass point, and `at` is walked round the arc rather than along a
1324
+ * straight edge, so points spaced a step apart on a side are a step apart on
1325
+ * the circle too. The line meets the circle square on, heading from its center.
1326
+ */
1327
+ function anchorOnCircle(face, side, at) {
1328
+ const radius = face.width / 2;
1329
+ const center = centerOf(face);
1330
+ const across = side === 'top' || side === 'bottom' ? at - center.x : at - center.y;
1331
+ const turn = Math.max(-Math.PI / 4, Math.min(Math.PI / 4, across / radius));
1332
+ // SVG's y runs down, so the bottom is a quarter-turn clockwise from the right.
1333
+ const angle = {
1334
+ right: turn,
1335
+ bottom: Math.PI / 2 - turn,
1336
+ left: Math.PI - turn,
1337
+ top: -Math.PI / 2 + turn,
1338
+ }[side];
1339
+ const tx = Math.cos(angle);
1340
+ const ty = Math.sin(angle);
1341
+ return { x: center.x + radius * tx, y: center.y + radius * ty, tx, ty, side };
1342
+ }
1305
1343
  /** An end with no side named: leave from the border, pointing at the far end. */
1306
1344
  function free(face, toward) {
1307
1345
  const point = sidePoint(face, toward);
@@ -1324,6 +1362,20 @@ function exitAlong(box, from, dir) {
1324
1362
  // is the graceful answer, and the crowding it signals is a diagnostic.
1325
1363
  const x = Math.min(Math.max(from.x, box.x), box.x + box.width);
1326
1364
  const y = Math.min(Math.max(from.y, box.y), box.y + box.height);
1365
+ if (box.round) {
1366
+ // Where the ray leaves the circle: the larger root of |p + t·dir − c| = r.
1367
+ const center = centerOf(box);
1368
+ const px = x - center.x;
1369
+ const py = y - center.y;
1370
+ const a = dir.x * dir.x + dir.y * dir.y;
1371
+ const b = px * dir.x + py * dir.y;
1372
+ const c = px * px + py * py - (box.width / 2) ** 2;
1373
+ const reach = b * b - a * c;
1374
+ if (a === 0 || reach < 0)
1375
+ return { x, y };
1376
+ const t = Math.max(0, (-b + Math.sqrt(reach)) / a);
1377
+ return { x: x + dir.x * t, y: y + dir.y * t };
1378
+ }
1327
1379
  const tx = dir.x === 0 ? Infinity : ((dir.x > 0 ? box.x + box.width : box.x) - x) / dir.x;
1328
1380
  const ty = dir.y === 0 ? Infinity : ((dir.y > 0 ? box.y + box.height : box.y) - y) / dir.y;
1329
1381
  const t = Math.min(tx, ty);
@@ -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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {