reladraw 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -82,7 +82,7 @@ That gets you a copy of the skill at the time you run it, so you'll need to re-r
82
82
 
83
83
  ## Status
84
84
 
85
- Version 0.13.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.
85
+ Version 0.14.1. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG, and an element that does the same inside a web page.
86
86
 
87
87
  The language isn't stable yet, so expect the syntax to change.
88
88
 
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.13.0
1
+ # Syntax reference — 0.14.1
2
2
 
3
3
  What the language accepts. The parser, resolver and SVG renderer implement all of it; the sections at the end record what is defective, unchecked or undecided.
4
4
 
@@ -396,12 +396,55 @@ Separation leaves a tight gap, deliberately small — enough to read as two node
396
396
  ## Edges
397
397
 
398
398
  ```
399
- edge <from> -> <to> ["<text>"] [between <a> and <b> [vertically|horizontally]] [attributes]
400
- edge <to> <- <from> ["<text>"] [between <a> and <b> [vertically|horizontally]] [attributes]
401
- edge <from> <-> <to> ["<text>"] [between <a> and <b> [vertically|horizontally]] [attributes]
399
+ edge <from> <arrow> <to> ["<text>"] [between <a> and <b> [vertically|horizontally]] [attributes]
402
400
  ```
403
401
 
404
- `a <- b` is exactly `b -> a` — same arrow, same picture. What changes is which name you write first, and that is worth having: the first name reads as the subject of the line, and plenty of edges are about the thing the arrow points at rather than the thing it leaves. `from:` and `to:` follow the arrow, not the writing order, so they still name the tail and the head.
402
+ ```
403
+ edge a -> b // an arrow at b
404
+ edge a <- b // an arrow at a
405
+ edge a <-> b // one at each end
406
+ edge a -- b // a plain line
407
+ ```
408
+
409
+ `a <- b` draws the same picture as `b -> a`. What changes is which name you write first, and that is worth having: the first name reads as the subject of the line, and plenty of edges are about the thing the arrow points at rather than the thing it leaves. **`from:` and `to:` follow the writing order**, never the arrow: `from:` is always the first name's side and `to:` the second's, whichever way the arrow points.
410
+
411
+ ### What is drawn at each end
412
+
413
+ The arrow is a mark, a line, a mark. The line is `-` or `--`, the two meaning the same, and either mark may be left off. A mark is a word, or a glyph that stands for one:
414
+
415
+ | mark | draws | glyph |
416
+ |---|---|---|
417
+ | `arrow` | a filled arrowhead | `>` at the right end, `<` at the left |
418
+ | `oarrow` | an outlined arrowhead | `\|>` at the right end, `<\|` at the left |
419
+ | `dot` | a filled circle | `*` |
420
+ | `odot` | an outlined circle | `o` |
421
+ | `diamond` | a filled diamond | |
422
+ | `odiamond` | an outlined diamond | |
423
+ | `bar` | a stroke across the line | `\|` |
424
+ | `none` | nothing | |
425
+
426
+ The `o` in front means outlined, for every shape. A glyph that points is spelled the way it points, which is why `<->` reads as it does. So these are all the same arrow, and you write whichever reads best to you:
427
+
428
+ ```
429
+ edge a o--> b
430
+ edge a odot--arrow b
431
+ edge a (odot)--(arrow) b
432
+ edge a [odot] -- [arrow] b
433
+ ```
434
+
435
+ A mark touches the dashes or is in brackets. `edge a -- dot b` is an error, because it reads just as well as a plain line to a node named `dot`.
436
+
437
+ **Marks as attributes.** `from-mark:` and `to-mark:` say the same thing, one end at a time, and they are how a style carries marks:
438
+
439
+ ```
440
+ style inherits to-mark: oarrow
441
+ edge dog -- animal style: inherits
442
+ edge cat -- animal style: inherits
443
+ ```
444
+
445
+ **An end with nothing written is left open, not empty.** `dog -- animal` is plain until something says otherwise, and here the style does. `none` is the word for "nothing at this end, whatever the style says": `edge a none--> b`, or `from-mark: none`.
446
+
447
+ **A mark written in the arrow is final.** The edge's own `to-mark:` may repeat it but not contradict it — `edge a --> b to-mark: dot` is an error, since both halves are on one line and one of them would be dead text. A style fills only the ends the arrow leaves open: a style giving every end a `bar`, applied to `a --> b`, draws a bar at `a` and still the arrow at `b`.
405
448
 
406
449
  Endpoints may be nested (`desktop1.files`). An edge never says where a node goes and routing is the renderer's problem, with one exception: an edge with text claims room in the gap it crosses, which is the next section.
407
450
 
@@ -454,7 +497,7 @@ The same rule covers every other case: sides at right angles where one faces awa
454
497
 
455
498
  Several lines along the same stretch take a lane each, side by side, instead of drawing on top of one another, nested so that none crosses another where it need not. Where one of them carries its text on that stretch, the others keep clear of the text as well as the line. The text rides on the line's longest straight piece that it does not land on a box from, and never changes which way the line goes.
456
499
 
457
- A curved line and a square one take the same way. The square one turns at corners. The curved one sweeps through them, each sweep as wide as the room around it allows without coming near a box or another line, and a line stepping sideways and carrying on the way it was going is one S. Where the room is tight a sweep comes close to a rounded corner; where there is room it flows. At an arrowhead, the line always finishes straight, so the head points square into the side.
500
+ A curved line and a square one take the same way. The square one turns at corners. The curved one sweeps through them, each sweep as wide as the room around it allows without coming near a box or another line, and a line stepping sideways and carrying on the way it was going is one S. Where the room is tight a sweep comes close to a rounded corner; where there is room it flows. At an arrowhead or any other mark, the line always finishes straight, so the mark points square into the side.
458
501
 
459
502
  When there is no way at all — the side you named is flush against another box — the file is refused with the reason: `edge a -> b: cannot leave a's right side, b is against it`.
460
503
 
@@ -769,6 +812,7 @@ Every attribute, and what takes one. The kinds here are what a node's **body** i
769
812
  | `shape` | ✓ | | ✓ | | the outline the node is drawn with, `none` included |
770
813
  | `icon` | | ✓ | | | the picture the node is drawn as |
771
814
  | `from` `to` | | | | ✓ | which side the line leaves and arrives on |
815
+ | `from-mark` `to-mark` | | | | ✓ | what is drawn at each end — see "What is drawn at each end" |
772
816
  | `fill` | ✓ | | | | color — see "A color names the part it colors" |
773
817
  | `border` | ✓ | | | | color |
774
818
  | `text` | ✓ | ✓ | ✓ | ✓ | the text's properties, in brackets — a style's form of what a node or an edge writes after its own words |
@@ -849,7 +893,7 @@ A color attribute says which part of a thing it colors, and a part exists only o
849
893
  | `fill` | the area inside the outline | a node |
850
894
  | `border` | the outline | a node |
851
895
  | `text: (color: …)` | the text | every node, and an edge |
852
- | `line` | the drawn line and its arrowheads | an edge |
896
+ | `line` | the drawn line and the marks at its ends | an edge |
853
897
 
854
898
  A word written on a kind that has no such part is refused by name, and the error lists the parts that kind does have — `border:` on a node with no body is a mistake, not something to ignore, for the same reason an unknown `diagram` key is.
855
899
 
@@ -1068,6 +1112,20 @@ That one was found by testing the lexer, not by rendering — and it could not h
1068
1112
 
1069
1113
  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.
1070
1114
 
1115
+ **0.14.1**
1116
+
1117
+ No syntax changes, and no file that worked is refused.
1118
+
1119
+ - Diagrams with many lines draw far faster. Since 0.13.0, the time to find the lines' ways grew much faster than the number of lines: a tangled diagram of 45 boxes and 48 lines took over half an hour. It now takes a few seconds, and ten lines take under one. No example diagram draws differently.
1120
+ - After every line is placed, lines that cross are placed again in the other order, to cross fewer. That now stops after as many attempts as there are lines. In a very tangled diagram a line may cross another where 0.13.0 and 0.14.0 found a way round; `from:`/`to:`, `below` and `between` say where a line should go.
1121
+
1122
+ **0.14.0**
1123
+
1124
+ One breaking change: on an edge written with `<-`, `from:` and `to:` now name the sides of the first and second names written. They used to follow the arrow, so `disk <- writer from: bottom` meant Writer's bottom; it now means Disk's. Swap the two on any `<-` edge that names a side. No other file draws differently.
1125
+
1126
+ - A line can have no arrowhead: `edge a -- b`, or `edge a - b`.
1127
+ - Each end of a line can carry a mark: an arrowhead filled or outlined, a dot, a diamond or a bar, written in the arrow (`a o--> b`, `a diamond--arrow b`) or as `from-mark:` and `to-mark:`, which a style can carry. See "What is drawn at each end".
1128
+
1071
1129
  **0.13.0**
1072
1130
 
1073
1131
  No syntax changes, and no file that worked is refused. Lines move: every line that goes round something, and every line with `below`/`above`/`left of`/`right of` or `between`, may be drawn differently.
package/dist/ast.d.ts CHANGED
@@ -276,12 +276,34 @@ export interface NodeStmt {
276
276
  attrs: Attrs;
277
277
  line: number;
278
278
  }
279
+ /**
280
+ * What may be drawn at an end of a line. The `o` prefix is the outlined form of
281
+ * the shape, one rule for every shape; `none` says there is definitely nothing,
282
+ * where an end with no mark written leaves it to a style.
283
+ */
284
+ export declare const MARKS: readonly ["arrow", "oarrow", "dot", "odot", "diamond", "odiamond", "bar", "none"];
285
+ export type Mark = (typeof MARKS)[number];
286
+ /**
287
+ * The glyphs that stand for a mark, by the end they are written at. A glyph
288
+ * that points is spelled the way it points, so `<` is the arrow at the left end
289
+ * and `>` the one at the right, which is what makes `<->` read. The diamonds
290
+ * have none: nothing would explain itself, and `<>` is two arrows.
291
+ */
292
+ export declare const MARK_GLYPHS: Record<'from' | 'to', Record<string, Mark>>;
293
+ /** The marks written in an edge's arrow, by end. An end with none written is absent. */
294
+ export interface EdgeMarks {
295
+ from?: Mark;
296
+ to?: Mark;
297
+ }
279
298
  export interface EdgeStmt {
280
299
  kind: 'edge';
300
+ /** The first name written. `from:` and `to:` follow writing order, never the arrow. */
281
301
  from: string;
282
302
  to: string;
283
- /** `<->` rather than `->`. */
284
- both: boolean;
303
+ /** The arrow as written, `->` or `o--odiamond`, for quoting back. */
304
+ arrow: string;
305
+ /** The marks the arrow writes at each end: `->` is an arrow at `to`, `<-` one at `from`. */
306
+ marks: EdgeMarks;
285
307
  text?: string;
286
308
  /** The text's bracketed modifiers, as written. Usually empty. */
287
309
  textAttrs: Attrs;
package/dist/ast.js CHANGED
@@ -240,6 +240,22 @@ export const LINE_VALUES = {
240
240
  pattern: ['solid', 'dashed', 'dotted', 'dash-dot'],
241
241
  thickness: ['normal', 'thin', 'thick'],
242
242
  };
243
+ /**
244
+ * What may be drawn at an end of a line. The `o` prefix is the outlined form of
245
+ * the shape, one rule for every shape; `none` says there is definitely nothing,
246
+ * where an end with no mark written leaves it to a style.
247
+ */
248
+ export const MARKS = ['arrow', 'oarrow', 'dot', 'odot', 'diamond', 'odiamond', 'bar', 'none'];
249
+ /**
250
+ * The glyphs that stand for a mark, by the end they are written at. A glyph
251
+ * that points is spelled the way it points, so `<` is the arrow at the left end
252
+ * and `>` the one at the right, which is what makes `<->` read. The diamonds
253
+ * have none: nothing would explain itself, and `<>` is two arrows.
254
+ */
255
+ export const MARK_GLYPHS = {
256
+ from: { '<': 'arrow', '<|': 'oarrow', '*': 'dot', o: 'odot', '|': 'bar' },
257
+ to: { '>': 'arrow', '|>': 'oarrow', '*': 'dot', o: 'odot', '|': 'bar' },
258
+ };
243
259
  /**
244
260
  * The attributes a `diagram` statement understands. `text` takes a bracket,
245
261
  * `text: (color: …)`, and sets the text color of everything at once — the
@@ -333,7 +349,7 @@ export const ATTR_KEYS = {
333
349
  shape: ['style', 'gap', 'overlap', 'contents', 'badge', 'deck', 'shape', 'fill', 'border', 'text', 'url'],
334
350
  icon: ['style', 'gap', 'overlap', 'icon', 'text', 'url'],
335
351
  none: ['style', 'gap', 'overlap', 'shape', 'text', 'url'],
336
- edge: ['style', 'from', 'to', 'line', 'text', 'url'],
352
+ edge: ['style', 'from', 'to', 'from-mark', 'to-mark', 'line', 'text', 'url'],
337
353
  };
338
354
  /**
339
355
  * Every word that is an attribute *somewhere*, which is what separates a
package/dist/grammar.d.ts CHANGED
@@ -38,7 +38,7 @@ export type TokenKind =
38
38
  | 'keyword'
39
39
  /** The name a statement declares, right after its keyword. */
40
40
  | 'name'
41
- /** `->` and `<->`. */
41
+ /** `->`, `<-`, `<->`, and `--` or `-` for a plain line. */
42
42
  | 'arrow'
43
43
  /** Placement and edge vocabulary: `right`, `of`, `level`, `with`, `and`, `between`, … */
44
44
  | 'relation'
@@ -71,19 +71,11 @@ export declare const STATEMENT_KEYWORDS: readonly ["node", "edge", "style", "dia
71
71
  * colors here without anybody remembering to come back.
72
72
  */
73
73
  export declare const RELATION_WORDS: string[];
74
- /**
75
- * The patterns, as source strings, so a generator can emit them into another
76
- * editor's grammar without reaching into a compiled RegExp. Each is written to
77
- * match at the point the scanner has reached; the scanner adds the sticky flag.
78
- *
79
- * A name may contain dots (containment) and hyphens, which is why the word
80
- * pattern is what it is rather than `\w+`.
81
- */
82
74
  export declare const PATTERNS: {
83
75
  readonly comment: "\\/\\/.*";
84
76
  readonly string: "\"(?:\\\\.|[^\"\\\\])*\"?";
85
77
  readonly keyword: `(?:${string})\\b`;
86
- readonly arrow: "<->|->|<-";
78
+ readonly arrow: `${string}?-{1,2}${string}?(?=[ \\t]|$)`;
87
79
  readonly attribute: "[A-Za-z][A-Za-z0-9_-]*:";
88
80
  readonly color: "#[0-9A-Fa-f]{3,8}\\b";
89
81
  readonly relation: `(?:${string})\\b`;
package/dist/grammar.js CHANGED
@@ -26,7 +26,7 @@
26
26
  * editor formats need and the reason a `.reladraw` grammar is small in all of
27
27
  * them.
28
28
  */
29
- import { DIRECTIONS, POSITIONS, SIDES, PASSAGE_AXES } from './ast.js';
29
+ import { DIRECTIONS, MARKS, POSITIONS, SIDES, PASSAGE_AXES } from './ast.js';
30
30
  /**
31
31
  * The words a statement may open with. `parseStatement` in `parser.ts` is the
32
32
  * authority — its switch is what actually accepts them — and this list mirrors
@@ -71,6 +71,9 @@ function alternation(words) {
71
71
  * A name may contain dots (containment) and hyphens, which is why the word
72
72
  * pattern is what it is rather than `\w+`.
73
73
  */
74
+ /** One end of an arrow: a mark word or glyph, bare or bracketed. Words first, so `odot` is not `o`. */
75
+ const MARK_WORD = `(?:${[...MARKS].sort((a, b) => b.length - a.length).join('|')}|<\\||\\|>|[<>*o|])`;
76
+ const ARROW_MARK = `(?:\\[${MARK_WORD}\\]|\\(${MARK_WORD}\\)|${MARK_WORD})`;
74
77
  export const PATTERNS = {
75
78
  comment: '\\/\\/.*',
76
79
  // The closing quote is optional: every string is unterminated for as long as
@@ -78,8 +81,10 @@ export const PATTERNS = {
78
81
  // rest of the file on every keystroke.
79
82
  string: '"(?:\\\\.|[^"\\\\])*"?',
80
83
  keyword: `(?:${alternation(STATEMENT_KEYWORDS)})\\b`,
81
- // `<->` first, or `<-` would match its opening half and leave a stray `>`.
82
- arrow: '<->|->|<-',
84
+ // A mark, one or two dashes, a mark — `->`, `o--odiamond`, `[dot]-`. It must
85
+ // end the token, so a hyphenated name stays a word. A mark written apart in
86
+ // brackets, `[dot] -- [arrow]`, colors as plain words either side of `--`.
87
+ arrow: `${ARROW_MARK}?-{1,2}${ARROW_MARK}?(?=[ \\t]|$)`,
83
88
  attribute: '[A-Za-z][A-Za-z0-9_-]*:',
84
89
  color: '#[0-9A-Fa-f]{3,8}\\b',
85
90
  relation: `(?:${alternation(RELATION_WORDS)})\\b`,
package/dist/model.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Attrs, Axis, Direction, Kind, Placement } from './ast.js';
1
+ import type { Attrs, Axis, Direction, Kind, Mark, Placement } from './ast.js';
2
2
  import type { Line } from './text.js';
3
3
  import type { Body } from './icons.js';
4
4
  /** A `between` clause with its targets resolved. Mirrors `Passage` in `ast.ts`. */
@@ -115,7 +115,11 @@ export interface LayoutNode {
115
115
  export interface LayoutEdge {
116
116
  from: LayoutNode;
117
117
  to: LayoutNode;
118
- both: boolean;
118
+ /** What is drawn at each end, settled: the arrow, then the edge's own attributes, then its style. */
119
+ marks: {
120
+ from: Mark;
121
+ to: Mark;
122
+ };
119
123
  text?: string;
120
124
  /** The text split into the lines that will be drawn, each as its runs. */
121
125
  lines?: Line[];
package/dist/parser.js CHANGED
@@ -1,4 +1,4 @@
1
- import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, LINE_KEYS, LINE_VALUES, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
1
+ import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, LINE_KEYS, LINE_VALUES, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, MARKS, MARK_GLYPHS, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
2
2
  import { SourceError } from './errors.js';
3
3
  import { STATEMENT_KEYWORDS } from './grammar.js';
4
4
  import { isAttrKey, tokenizeLine } from './lexer.js';
@@ -516,27 +516,109 @@ function parseNode(head, line) {
516
516
  line,
517
517
  };
518
518
  }
519
+ const NEEDS_ARROW = 'an edge needs a line between its endpoints: "->", "<-", "<->" or "--"';
519
520
  /**
520
- * `edge <from> -> <to> ["<text>"] [between <a> and <b>]`, with `<->` for a
521
- * two-headed arrow and `<-` for one pointing the other way.
521
+ * The arrow between an edge's two names: a mark, a shaft, a mark. The shaft is
522
+ * `-` or `--`, and either mark may be left off, so `--` is a plain line and
523
+ * `<->` an arrow at each end.
522
524
  *
523
- * `a <- b` is exactly `b -> a` and carries no meaning of its own downstream.
524
- * What it buys is the ordering: the name written first is the one the line is
525
- * about, and plenty of edges have the target as their subject.
525
+ * A mark is a word or a glyph, bare or in `()` or `[]`, and it must touch the
526
+ * dashes or be bracketed: `edge a -- dot b` would otherwise read like a line to
527
+ * a node named `dot`. So a bracketed mark may stand apart, which is why the
528
+ * arrow can span several tokens — `[dot] -- [arrow]` is three, and `(dot)` is
529
+ * three on its own, since the lexer splits a bracket that opens a token.
530
+ */
531
+ function readArrow(head, line) {
532
+ let at = 2;
533
+ const pieces = [];
534
+ const bracketed = () => {
535
+ const token = head[at];
536
+ if (!token || token.quoted)
537
+ return;
538
+ if (token.text === '(' && head[at + 2]?.text === ')' && !head[at + 1].quoted) {
539
+ pieces.push(`(${head[at + 1].text})`);
540
+ at += 3;
541
+ }
542
+ else if (/^\[[^\]]*\]$/.test(token.text)) {
543
+ pieces.push(token.text);
544
+ at += 1;
545
+ }
546
+ };
547
+ bracketed();
548
+ const shaft = head[at];
549
+ if (!shaft || shaft.quoted || !shaft.text.includes('-'))
550
+ throw new SourceError(NEEDS_ARROW, line);
551
+ pieces.push(shaft.text);
552
+ at += 1;
553
+ if (shaft.text.endsWith('-'))
554
+ bracketed();
555
+ const text = pieces.join('');
556
+ const parts = /^([^-]*)(-+)([^-]*)$/.exec(text);
557
+ if (!parts) {
558
+ throw new SourceError(`"${text}" is not an arrow — the line is one run of dashes, with a mark at either end`, line);
559
+ }
560
+ if (parts[2].length > 2) {
561
+ throw new SourceError(`"${text}" has ${parts[2].length} dashes — the line is "-" or "--"`, line);
562
+ }
563
+ const from = readMark(parts[1], 'from', text, line);
564
+ const to = readMark(parts[3], 'to', text, line);
565
+ return {
566
+ text,
567
+ marks: { ...(from !== undefined ? { from } : {}), ...(to !== undefined ? { to } : {}) },
568
+ next: at,
569
+ };
570
+ }
571
+ /** One end of an arrow, as written: a mark word or glyph, perhaps bracketed, or nothing. */
572
+ function readMark(written, end, arrow, line) {
573
+ if (written === '')
574
+ return undefined;
575
+ const inner = /^\((.*)\)$/.exec(written)?.[1] ?? /^\[(.*)\]$/.exec(written)?.[1] ?? written;
576
+ if (MARKS.includes(inner))
577
+ return inner;
578
+ const glyph = MARK_GLYPHS[end][inner];
579
+ if (glyph !== undefined)
580
+ return glyph;
581
+ const other = end === 'from' ? 'to' : 'from';
582
+ const turned = MARK_GLYPHS[other][inner];
583
+ if (turned !== undefined) {
584
+ // A pointing glyph written at the end it points away from: `>--` or `--<`.
585
+ const right = Object.entries(MARK_GLYPHS[end]).find(([, mark]) => mark === turned)[0];
586
+ throw new SourceError(`"${arrow}": "${inner}" points the other way — at the ${end === 'from' ? 'left' : 'right'} end ` +
587
+ `the ${turned} is "${right}", or write the word "${turned}"`, line);
588
+ }
589
+ throw new SourceError(`"${arrow}": "${inner}" is not a mark. The marks are ${MARKS.join(', ')}, and the glyphs ` +
590
+ `${Object.keys(MARK_GLYPHS.to).join(' ')} and ${Object.keys(MARK_GLYPHS.from).filter((g) => !(g in MARK_GLYPHS.to)).join(' ')}`, line);
591
+ }
592
+ /**
593
+ * `edge <from> <arrow> <to> ["<text>"] [between <a> and <b>]`. The arrow is
594
+ * read by `readArrow`.
595
+ *
596
+ * `from` and `to` are the order the names are written in, never the arrow's
597
+ * direction: `a <- b` is `a` then `b` with an arrow at `a`. With a mark at each
598
+ * end many lines have no direction at all, and the written order is the one
599
+ * every edge has — so `from:` names the first name's side on every edge.
526
600
  */
527
- const ARROWS = ['->', '<->', '<-'];
528
601
  function parseEdge(head, line) {
529
602
  const left = requireName(head[1], 'edge', line);
530
- const arrow = head[2];
531
- if (!arrow || arrow.quoted || !ARROWS.includes(arrow.text)) {
532
- throw new SourceError('an edge needs "->", "<-" or "<->" between its endpoints', line);
533
- }
534
- const rightToken = head[3];
603
+ const arrow = readArrow(head, line);
604
+ const rightToken = head[arrow.next];
535
605
  if (!rightToken || rightToken.quoted) {
536
606
  throw new SourceError('an edge needs a node on the right of the arrow', line);
537
607
  }
538
- const back = arrow.text === '<-';
539
- let at = 4;
608
+ let at = arrow.next + 1;
609
+ const stray = head[at];
610
+ if (stray &&
611
+ !stray.quoted &&
612
+ !isAttrKey(stray) &&
613
+ stray.text !== '(' &&
614
+ stray.text !== 'between' &&
615
+ !isDirection(stray.text) &&
616
+ (MARKS.includes(rightToken.text) || Object.hasOwn(MARK_GLYPHS.to, rightToken.text))) {
617
+ // `edge a -- dot b`: a mark standing apart, which reads as a node named
618
+ // `dot`. Said outright rather than left to the "not a direction" error.
619
+ throw new SourceError(`edge ${left} ${arrow.text} ${rightToken.text} ${stray.text}: a mark touches the dashes or is ` +
620
+ `in brackets — write "${arrow.text}${rightToken.text}" or "${arrow.text} [${rightToken.text}]"`, line);
621
+ }
540
622
  const textToken = head[at]?.quoted ? head[at] : undefined;
541
623
  if (textToken)
542
624
  at += 1;
@@ -603,9 +685,10 @@ function parseEdge(head, line) {
603
685
  return {
604
686
  kind: 'edge',
605
687
  textAttrs: bracket.values,
606
- from: back ? rightToken.text : left,
607
- to: back ? left : rightToken.text,
608
- both: arrow.text === '<->',
688
+ from: left,
689
+ to: rightToken.text,
690
+ arrow: arrow.text,
691
+ marks: arrow.marks,
609
692
  ...(textToken ? { text: textToken.text } : {}),
610
693
  ...(between ? { between } : {}),
611
694
  ...(passes.length > 0 ? { passes } : {}),
package/dist/render.js CHANGED
@@ -50,7 +50,7 @@ export function render(layout, options = {}) {
50
50
  const coming = [...routes]
51
51
  .filter(([other]) => other !== edge && !sweeps.has(other))
52
52
  .map(([, way]) => way.points);
53
- const d = sweptPath(route.points, sweepObstacles(edge, layout.nodes), [...swept, ...coming], arrowLength(edge.look.thickness), edge.both);
53
+ const d = sweptPath(route.points, sweepObstacles(edge, layout.nodes), [...swept, ...coming], arrowLength(edge.look.thickness), edge.marks);
54
54
  sweeps.set(edge, d);
55
55
  swept.push(traceOf(`d="${d}"`));
56
56
  }
@@ -91,11 +91,19 @@ export function render(layout, options = {}) {
91
91
  width: Math.ceil(ink.maxX) - Math.floor(ink.minX),
92
92
  height: Math.ceil(ink.maxY) - Math.floor(ink.minY),
93
93
  };
94
- const arrowColors = new Set(layout.edges.map((edge) => lineOf(edge.appearance, theme, theme.edge)));
94
+ // One marker for each mark in each line color the drawing actually uses.
95
+ const markers = new Map();
96
+ for (const edge of layout.edges) {
97
+ const color = lineOf(edge.appearance, theme, theme.edge);
98
+ for (const mark of [edge.marks.from, edge.marks.to]) {
99
+ if (mark !== 'none')
100
+ markers.set(markerId(mark, color), [mark, color]);
101
+ }
102
+ }
95
103
  const svg = [
96
104
  `<svg xmlns="http://www.w3.org/2000/svg" width="${canvas.width}" height="${canvas.height}" viewBox="${canvas.x} ${canvas.y} ${canvas.width} ${canvas.height}" font-family=${quote(measurer.fontFamily)} font-size="${fontSize}px">`,
97
105
  ' <defs>',
98
- ...[...arrowColors].map((color) => arrowMarker(color)),
106
+ ...[...markers.values()].map(([mark, color]) => markMarker(mark, color, theme.background)),
99
107
  // Each mask covers the whole page, in page units: the default region is the
100
108
  // line's own bounding box, which a straight level line gives no height, and
101
109
  // the line would vanish entirely.
@@ -346,8 +354,8 @@ function drawEdge(edge, ends, route, sweep, nodes, theme, measurer, fontSize, ma
346
354
  const { start, end } = ends;
347
355
  const color = lineOf(edge.appearance, theme, theme.edge);
348
356
  const stroke = strokeOf(color, edge.look);
349
- const markerEnd = ` marker-end="url(#${markerId(color)})"`;
350
- const markerStart = edge.both ? ` marker-start="url(#${markerId(color)}-back)"` : '';
357
+ const markerEnd = edge.marks.to !== 'none' ? ` marker-end="url(#${markerId(edge.marks.to, color)})"` : '';
358
+ const markerStart = edge.marks.from !== 'none' ? ` marker-start="url(#${markerId(edge.marks.from, color)})"` : '';
351
359
  // A named side is a statement about how the line should leave or arrive, so
352
360
  // it is drawn as a curve that actually does leave and arrive that way. With
353
361
  // neither side named there is nothing to honor and the line stays straight.
@@ -1842,15 +1850,21 @@ function planWays(edges, nodes, ends, corridors, measurer, fontSize) {
1842
1850
  // Which line takes the inside of a shared stretch depends on which was placed
1843
1851
  // first, and the wrong order makes two lines cross where they need not. So a
1844
1852
  // pair that crosses is placed again the other way round, and kept that way if
1845
- // it crosses less.
1853
+ // it crosses less. This is tidying, not a promise: the pairs are tried a
1854
+ // fixed number of times over, however tangled the diagram, since trying every
1855
+ // pair that crossed made a diagram of forty lines take minutes. A count, not a
1856
+ // clock, so a file draws the same on every machine. A writer who wants a line
1857
+ // somewhere in particular says so.
1846
1858
  const crossingsOf = (edge) => order.reduce((sum, other) => (other === edge ? sum : sum + linesCross(found.get(edge).points, found.get(other).points)), 0);
1847
- for (let pass = 0; pass < 3; pass += 1) {
1859
+ let tries = order.length * UNCROSS_TRIES;
1860
+ for (let pass = 0; pass < 3 && tries > 0; pass += 1) {
1848
1861
  let better = false;
1849
- for (let i = 0; i < order.length; i += 1) {
1850
- for (let j = i + 1; j < order.length; j += 1) {
1862
+ for (let i = 0; i < order.length && tries > 0; i += 1) {
1863
+ for (let j = i + 1; j < order.length && tries > 0; j += 1) {
1851
1864
  const [first, second] = [order[i], order[j]];
1852
1865
  if (linesCross(found.get(first).points, found.get(second).points) === 0)
1853
1866
  continue;
1867
+ tries -= 1;
1854
1868
  const before = crossingsOf(first) + crossingsOf(second);
1855
1869
  const kept = [found.get(first), found.get(second)];
1856
1870
  const rest = order.filter((edge) => edge !== first && edge !== second);
@@ -2188,6 +2202,8 @@ function textOnWay(points, edge, nodes, measurer, fontSize) {
2188
2202
  }
2189
2203
  /** How much a turn costs the search, in the same units as length. */
2190
2204
  const SEARCH_TURN = SEPARATION_GAP * 2;
2205
+ /** How many crossing pairs, per line placed, may be placed again the other way round. */
2206
+ const UNCROSS_TRIES = 1;
2191
2207
  /**
2192
2208
  * The searched way for one edge, or one half of a `between` edge. A named side
2193
2209
  * is fixed; an end with no side named may leave or arrive by any of its box's
@@ -2294,6 +2310,9 @@ function searchWay(edge, nodes, start, startNode, end, endNode, taken, lane, use
2294
2310
  taken,
2295
2311
  lane,
2296
2312
  ...more,
2313
+ // Only a way cheaper than the best so far is taken, so a search that
2314
+ // cannot find one stops early.
2315
+ limit: best ? best.cost - 1e-6 : Infinity,
2297
2316
  bounds: wallOf(grow(nodes.map((node) => extentOfBox(faceOf(node))).reduce(union), ROUTE_STUB * 4)),
2298
2317
  });
2299
2318
  if (found && (!best || found.cost < best.cost - 1e-6))
@@ -2382,8 +2401,8 @@ function sideCurve(edge, { start, end, bow }) {
2382
2401
  const tip = (anchor, arrowed) => arrowed && anchor.side !== undefined
2383
2402
  ? { x: anchor.x + anchor.tx * head, y: anchor.y + anchor.ty * head }
2384
2403
  : { x: anchor.x, y: anchor.y };
2385
- const p0 = tip(start, edge.both);
2386
- const p3 = tip(end, true);
2404
+ const p0 = tip(start, edge.marks.from !== 'none');
2405
+ const p3 = tip(end, edge.marks.to !== 'none');
2387
2406
  const reach = controlReach(start, end);
2388
2407
  // A bundle whose sides were too short to spread it takes the rest of the
2389
2408
  // room in the middle — see `bowBundles`. Displacing both control points
@@ -2503,7 +2522,7 @@ const SWEEP_AIR = 5;
2503
2522
  * has. So where the room is tight the line looks nearly square, and where there
2504
2523
  * is room it flows.
2505
2524
  */
2506
- function sweptPath(points, boxes, neighbors, arrow, both) {
2525
+ function sweptPath(points, boxes, neighbors, arrow, marks) {
2507
2526
  const kappa = 0.5523;
2508
2527
  const count = points.length;
2509
2528
  const legLength = (leg) => Math.hypot(points[leg + 1].x - points[leg].x, points[leg + 1].y - points[leg].y);
@@ -2515,9 +2534,9 @@ function sweptPath(points, boxes, neighbors, arrow, both) {
2515
2534
  const room = (leg) => {
2516
2535
  const length = legLength(leg);
2517
2536
  if (leg === 0)
2518
- return Math.max(0, length - (both ? arrow : 0));
2537
+ return Math.max(0, length - (marks.from !== 'none' ? arrow : 0));
2519
2538
  if (leg === count - 2)
2520
- return Math.max(0, length - arrow);
2539
+ return Math.max(0, length - (marks.to !== 'none' ? arrow : 0));
2521
2540
  return length / 2;
2522
2541
  };
2523
2542
  const cubicAt = (p0, c1, c2, p3, t) => {
@@ -2742,19 +2761,46 @@ function impliedSide(edge, key) {
2742
2761
  // Across first, then down: the start leaves across and the end is reached down.
2743
2762
  return key === 'from' ? across : upDown;
2744
2763
  }
2745
- function arrowMarker(color) {
2746
- const id = markerId(color);
2764
+ /**
2765
+ * The shape of each mark, in a 10 by 10 box whose right edge is the end of the
2766
+ * line, pointing along it. The marker scales with the stroke, so a unit is 0.7
2767
+ * of the line's width and an outline of 1.4 units is as thick as the line.
2768
+ *
2769
+ * An outlined mark is filled with the page's background so the line running
2770
+ * under it does not show through. Over a filled container that is the wrong
2771
+ * color inside the outline; nothing has hit it yet.
2772
+ */
2773
+ function markShape(mark, color, background) {
2774
+ const outline = `fill="${background}" stroke="${color}" stroke-width="1.4"`;
2775
+ switch (mark) {
2776
+ case 'arrow':
2777
+ return `<path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`;
2778
+ case 'oarrow':
2779
+ return `<path d="M 0.7 1.2 L 9.3 5 L 0.7 8.8 z" ${outline}/>`;
2780
+ case 'dot':
2781
+ return `<circle cx="5" cy="5" r="4" fill="${color}"/>`;
2782
+ case 'odot':
2783
+ return `<circle cx="5.3" cy="5" r="3.4" ${outline}/>`;
2784
+ case 'diamond':
2785
+ return `<path d="M 0 5 L 5 1.2 L 10 5 L 5 8.8 z" fill="${color}"/>`;
2786
+ case 'odiamond':
2787
+ return `<path d="M 0.9 5 L 5 1.9 L 9.1 5 L 5 8.1 z" ${outline}/>`;
2788
+ case 'bar':
2789
+ return `<rect x="5.5" y="0.5" width="1.4" height="9" fill="${color}"/>`;
2790
+ case 'none':
2791
+ return '';
2792
+ }
2793
+ }
2794
+ /** One marker, drawn at either end: `auto-start-reverse` turns it round at the start. */
2795
+ function markMarker(mark, color, background) {
2747
2796
  return [
2748
- ` <marker id="${id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
2749
- ` <path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`,
2750
- ' </marker>',
2751
- ` <marker id="${id}-back" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
2752
- ` <path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`,
2797
+ ` <marker id="${markerId(mark, color)}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
2798
+ ` ${markShape(mark, color, background)}`,
2753
2799
  ' </marker>',
2754
2800
  ].join('\n');
2755
2801
  }
2756
- function markerId(color) {
2757
- return `arrow-${color.replace(/[^a-zA-Z0-9]/g, '')}`;
2802
+ function markerId(mark, color) {
2803
+ return `${mark}-${color.replace(/[^a-zA-Z0-9]/g, '')}`;
2758
2804
  }
2759
2805
  /** The rectangle actually drawn. Differs from the node box only for a deck. */
2760
2806
  function faceOf(node) {
package/dist/resolve.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ALL_ATTR_KEYS, ATTR_KEYS, PART_SIDES, COLOR_KEYS, COLOR_PARTS, CONTENT_ALIGNMENTS, CONTENT_WIDTHS, LINE_VALUES, describePlacement, nameTarget, } from './ast.js';
1
+ import { ALL_ATTR_KEYS, ATTR_KEYS, MARKS, PART_SIDES, COLOR_KEYS, COLOR_PARTS, CONTENT_ALIGNMENTS, CONTENT_WIDTHS, LINE_VALUES, describePlacement, nameTarget, } from './ast.js';
2
2
  import { BARE_EDGE_RUN, CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, TEXT_CLEARANCE, PAD, SEPARATION_GAP, THICKNESS, arrowLength, fontSizeFor, leafTop, textExtent, textStyleFor, widestLine, } from './constants.js';
3
3
  import { fix, reachability, tightest } from './constrain.js';
4
4
  import { SourceError } from './errors.js';
@@ -430,6 +430,34 @@ function appearanceOf(attrs, styles, line) {
430
430
  }
431
431
  return { ...merged, ...attrs };
432
432
  }
433
+ /**
434
+ * What is drawn at one end of an edge. A mark written in the arrow is final:
435
+ * the edge's own `from-mark:` or `to-mark:` may repeat it but not contradict
436
+ * it, since both halves are on one line and whichever lost would be dead text.
437
+ * An end the arrow leaves blank is unsaid, and the edge's attribute, then its
438
+ * style, then nothing, fills it. Layering is between places, never within one.
439
+ */
440
+ function markAt(end, stmt, appearance, what) {
441
+ const key = `${end}-mark`;
442
+ const check = (value) => {
443
+ if (!MARKS.includes(value)) {
444
+ throw new SourceError(`edge ${what}: "${key}: ${value}" is not a mark — use ${MARKS.join(', ')}`, stmt.line);
445
+ }
446
+ return value;
447
+ };
448
+ const written = stmt.marks[end];
449
+ const own = stmt.attrs[key];
450
+ if (written !== undefined) {
451
+ if (own !== undefined && check(own) !== written) {
452
+ throw new SourceError(`edge ${what}: the arrow already puts ${written === 'none' ? 'nothing' : `a${/^[aeiou]/.test(written) ? 'n' : ''} ${written}`} ` +
453
+ `at ${stmt[end]}, and "${key}: ${own}" says otherwise. Say it once: leave that end of the ` +
454
+ `arrow blank and keep "${key}: ${own}", or drop "${key}:"`, stmt.line);
455
+ }
456
+ return written;
457
+ }
458
+ const given = appearance[key];
459
+ return given === undefined ? 'none' : check(given);
460
+ }
433
461
  function buildEdges(statements, byName, styles, defaults) {
434
462
  const edges = [];
435
463
  for (const stmt of statements) {
@@ -477,7 +505,7 @@ function buildEdges(statements, byName, styles, defaults) {
477
505
  };
478
506
  });
479
507
  const appearance = { ...defaults.get('edge'), ...appearanceOf(stmt.attrs, styles, stmt.line) };
480
- const what = `${stmt.from} -> ${stmt.to}`;
508
+ const what = `${stmt.from} ${stmt.arrow} ${stmt.to}`;
481
509
  checkAttrs('edge', what, stmt.attrs, stmt.line);
482
510
  checkStyleUse('edge', what, stmt.attrs, styles, stmt.line);
483
511
  if (stmt.attrs['url'] !== undefined && stmt.text === undefined) {
@@ -492,7 +520,10 @@ function buildEdges(statements, byName, styles, defaults) {
492
520
  edges.push({
493
521
  from,
494
522
  to,
495
- both: stmt.both,
523
+ marks: {
524
+ from: markAt('from', stmt, appearance, what),
525
+ to: markAt('to', stmt, appearance, what),
526
+ },
496
527
  textAttrs,
497
528
  ...(stmt.text !== undefined
498
529
  ? { text: stmt.text, lines: linesFor(stmt.text, textAttrs, `edge ${what}`, stmt.line) }
@@ -1468,10 +1499,11 @@ function corridorsIn(edges, locate, measurer, fontSize) {
1468
1499
  const to = locate(edge.to);
1469
1500
  if (!from || !to || from.index === to.index)
1470
1501
  continue;
1471
- // With no text, the gap still has to show a run of line behind each head,
1502
+ // With no text, the gap still has to show a run of line behind each mark,
1472
1503
  // or two stacked children with an arrow between them draw as a head alone.
1504
+ const marked = [edge.marks.from, edge.marks.to].filter((mark) => mark !== 'none').length;
1473
1505
  if (edge.text === undefined) {
1474
- const bare = BARE_EDGE_RUN + arrowLength(edge.look.thickness) * (edge.both ? 2 : 1);
1506
+ const bare = BARE_EDGE_RUN + arrowLength(edge.look.thickness) * marked;
1475
1507
  corridors.push({
1476
1508
  edge,
1477
1509
  from,
@@ -1486,9 +1518,11 @@ function corridorsIn(edges, locate, measurer, fontSize) {
1486
1518
  // at either end. The arrowhead is charged on both sides for the same reason:
1487
1519
  // it covers the head's length of the line it arrives on, and reserving that
1488
1520
  // at one end only would move the midpoint rather than lengthen the run. A
1489
- // thick line's head is longer, and takes the room it needs.
1521
+ // thick line's head is longer, and takes the room it needs. A line with no
1522
+ // mark at either end is charged for none.
1523
+ const head = marked > 0 ? arrowLength(edge.look.thickness) : 0;
1490
1524
  const extent = (axis) => textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line) +
1491
- (TEXT_CLEARANCE + arrowLength(edge.look.thickness)) * 2;
1525
+ (TEXT_CLEARANCE + head) * 2;
1492
1526
  corridors.push({
1493
1527
  edge,
1494
1528
  from,
package/dist/search.d.ts CHANGED
@@ -79,6 +79,12 @@ export interface SearchOptions {
79
79
  * cannot be gone round at its far end.
80
80
  */
81
81
  bounds?: SearchWall;
82
+ /**
83
+ * A cost the line is no use at unless it comes in under: the cheapest way
84
+ * already found between another pair of sides. The search gives up as soon
85
+ * as it cannot.
86
+ */
87
+ limit?: number;
82
88
  }
83
89
  /** A gap a line must pass through. See `SearchOptions.gate`. */
84
90
  export interface SearchGate {
package/dist/search.js CHANGED
@@ -45,6 +45,10 @@ export function searchRoute(start, end, walls, options) {
45
45
  return undefined;
46
46
  if (inside(out, walls) || inside(back, walls))
47
47
  return undefined;
48
+ // No way between the two is shorter than straight across and straight down.
49
+ const limit = options.limit ?? Infinity;
50
+ if (Math.abs(back.x - out.x) + Math.abs(back.y - out.y) - 1e-6 >= limit)
51
+ return undefined;
48
52
  const bounds = options.bounds;
49
53
  const gate = options.gate;
50
54
  // How far a stretch keeps from a placed one beside it: a lane, or clear of
@@ -68,7 +72,8 @@ export function searchRoute(start, end, walls, options) {
68
72
  const iy = new Map(ys.map((y, index) => [y, index]));
69
73
  const width = xs.length;
70
74
  const height = ys.length;
71
- const open = (i, j) => !inside({ x: xs[i], y: ys[j] }, walls);
75
+ const steps = stepTable(xs, ys, walls, clear, options.taken, spacing, ownHalf, (a, b) => (inGate(a, b) ? ownHalf : 0));
76
+ const open = (i, j) => steps.open[j * width + i] === 1;
72
77
  // Four headings: 0 right, 1 down, 2 left, 3 up.
73
78
  const STEP = [
74
79
  [1, 0],
@@ -95,26 +100,40 @@ export function searchRoute(start, end, walls, options) {
95
100
  const from = new Int32Array(size).fill(-1);
96
101
  const state = (i, j, h, p) => ((j * width + i) * 4 + h) * 2 + p;
97
102
  const done = gate ? 1 : 0;
103
+ // The search reaches out toward the end first: a state waits by what it has
104
+ // cost so far plus the least the rest could cost, straight across and
105
+ // straight down to the end. It does not settle each state once, since a
106
+ // way just as long can arrive later with a better tie-break; it keeps on
107
+ // until nothing waiting could come in as cheap as the end already has, and
108
+ // what it finds is exactly what searching every direction alike would.
109
+ const toGo = (current) => {
110
+ const cell = (current - (current % 8)) / 8;
111
+ const i = cell % width;
112
+ return Math.abs(xs[i] - back.x) + Math.abs(ys[(cell - i) / width] - back.y);
113
+ };
98
114
  const heap = new MinHeap();
99
115
  const begin = state(si, sj, first, 0);
116
+ const goal = state(ti, tj, last, done);
100
117
  cost[begin] = 0;
101
118
  tie[begin] = 0;
102
- heap.push(begin, 0, 0);
103
- let goal = -1;
119
+ heap.push(begin, toGo(begin), 0);
104
120
  while (heap.size > 0) {
105
- const [current, c, t] = heap.pop();
106
- if (c > cost[current] || (c === cost[current] && t > tie[current]))
121
+ const [current, f, t] = heap.pop();
122
+ const c = cost[current];
123
+ const due = c + toGo(current);
124
+ if (f > due || (f === due && t > tie[current]))
107
125
  continue;
126
+ // Everything still waiting costs at least this much.
127
+ if (f >= limit || f > cost[goal] + 1e-9)
128
+ break;
108
129
  const p = current % 2;
109
130
  const rest = (current - p) / 2;
110
131
  const h = rest % 4;
111
132
  const cell = (rest - h) / 4;
112
133
  const i = cell % width;
113
134
  const j = (cell - i) / width;
114
- if (i === ti && j === tj && h === last && p === done) {
115
- goal = current;
116
- break;
117
- }
135
+ if (current === goal)
136
+ continue;
118
137
  for (let next = 0; next < 4; next += 1) {
119
138
  // A line never doubles straight back on itself.
120
139
  if ((next + 2) % 4 === h)
@@ -128,11 +147,13 @@ export function searchRoute(start, end, walls, options) {
128
147
  const nj = j + STEP[next][1];
129
148
  if (ni < 0 || nj < 0 || ni >= width || nj >= height || !open(ni, nj))
130
149
  continue;
150
+ // A step is filed under the point it starts from on its way right or down.
151
+ const axis = next % 2;
152
+ const key = next < 2 ? j * width + i : nj * width + ni;
153
+ if (steps.closed[axis][key] === 1)
154
+ continue;
131
155
  const a = { x: xs[i], y: ys[j] };
132
156
  const b = { x: xs[ni], y: ys[nj] };
133
- const own = inGate(a, b) ? ownHalf : 0;
134
- if (blocked(a, b, walls) || alongTaken(a, b, options.taken, spacing, own))
135
- continue;
136
157
  // Passing the gate: a stretch through it, reaching the line at `at`.
137
158
  const through = inGate(a, b) &&
138
159
  Math.min(gate.run === 'x' ? a.x : a.y, gate.run === 'x' ? b.x : b.y) <= gate.at + 0.01 &&
@@ -142,8 +163,8 @@ export function searchRoute(start, end, walls, options) {
142
163
  // a narrow gap, but where there is room it keeps its distance. Crossing a
143
164
  // line already placed costs what a turn does, so of two ways much the
144
165
  // same length the one that crosses nothing wins.
145
- const length = Math.abs(b.x - a.x) + Math.abs(b.y - a.y) + crowding(a, b, walls, clear) +
146
- turn * crossings(a, b, options.taken);
166
+ const length = Math.abs(b.x - a.x) + Math.abs(b.y - a.y) + steps.crowd[axis][key] +
167
+ turn * steps.cross[axis][key];
147
168
  // Higher up for a stretch across, further right for one down: a smaller
148
169
  // tie-break is preferred, so the across level counts as it is and the
149
170
  // down level counts negated.
@@ -151,14 +172,14 @@ export function searchRoute(start, end, walls, options) {
151
172
  relax(current, state(ni, nj, next, np), c + length, t + lean);
152
173
  }
153
174
  }
154
- if (goal < 0)
175
+ if (!(cost[goal] < limit))
155
176
  return undefined;
156
177
  function relax(previous, next, c, t) {
157
178
  if (c < cost[next] - 1e-9 || (Math.abs(c - cost[next]) <= 1e-9 && t < tie[next])) {
158
179
  cost[next] = c;
159
180
  tie[next] = t;
160
181
  from[next] = previous;
161
- heap.push(next, c, t);
182
+ heap.push(next, c + toGo(next), t);
162
183
  }
163
184
  }
164
185
  const path = [];
@@ -255,83 +276,139 @@ function inside(point, walls) {
255
276
  }
256
277
  /** Whether a level or upright stretch passes through the inside of any wall. */
257
278
  function blocked(a, b, walls) {
279
+ return walls.some((wall) => blocks(a, b, wall));
280
+ }
281
+ /** Whether a level or upright stretch passes through the inside of one wall. */
282
+ function blocks(a, b, wall) {
258
283
  const minX = Math.min(a.x, b.x);
259
284
  const maxX = Math.max(a.x, b.x);
260
285
  const minY = Math.min(a.y, b.y);
261
286
  const maxY = Math.max(a.y, b.y);
262
- return walls.some((wall) => {
263
- if (minY === maxY)
264
- return minY > wall.minY && minY < wall.maxY && maxX > wall.minX && minX < wall.maxX;
265
- return minX > wall.minX && minX < wall.maxX && maxY > wall.minY && minY < wall.maxY;
266
- });
287
+ if (minY === maxY)
288
+ return minY > wall.minY && minY < wall.maxY && maxX > wall.minX && minX < wall.maxX;
289
+ return minX > wall.minX && minX < wall.maxX && maxY > wall.minY && minY < wall.maxY;
267
290
  }
268
291
  /**
269
- * How much of a stretch runs within `clear` of a wall's side, alongside it:
270
- * the longest such run against any one wall.
292
+ * Every single step through the grid — from a point to its neighbor on the
293
+ * right, or the one below — and what it costs beyond its length: whether it is
294
+ * closed, by a wall or by running along a placed line; how far it runs close
295
+ * beside a wall; how many placed lines it crosses. The search takes each step
296
+ * many times over, once for each heading it arrives with, so these are worked
297
+ * out here once, and each wall and each placed stretch is tested only against
298
+ * the steps that lie near it. A step is filed at the index of the point it
299
+ * starts from: `[0]` the step right, `[1]` the step down.
271
300
  */
272
- function crowding(a, b, walls, clear) {
301
+ function stepTable(xs, ys, walls, clear, taken, spacing, widest, ownAt) {
302
+ const width = xs.length;
303
+ const height = ys.length;
304
+ const cells = width * height;
305
+ const open = new Uint8Array(cells).fill(1);
306
+ const closed = [new Uint8Array(cells), new Uint8Array(cells)];
307
+ const crowd = [new Float64Array(cells), new Float64Array(cells)];
308
+ const cross = [new Int32Array(cells), new Int32Array(cells)];
309
+ // The indices of the tracks lying within `lo`..`hi`, with one to spare either
310
+ // side: a range to test exactly, never a test itself.
311
+ const near = (values, lo, hi) => [Math.max(0, firstAtLeast(values, lo) - 1), Math.min(values.length - 1, firstAtLeast(values, hi) + 1)];
312
+ const each = ([i0, i1], [j0, j1], visit) => {
313
+ for (let j = j0; j <= j1; j += 1) {
314
+ for (let i = i0; i <= i1; i += 1) {
315
+ const a = { x: xs[i], y: ys[j] };
316
+ if (i + 1 < width)
317
+ visit(0, a, { x: xs[i + 1], y: ys[j] }, j * width + i);
318
+ if (j + 1 < height)
319
+ visit(1, a, { x: xs[i], y: ys[j + 1] }, j * width + i);
320
+ }
321
+ }
322
+ };
323
+ for (const wall of walls) {
324
+ const [i0, i1] = near(xs, wall.minX, wall.maxX);
325
+ const [j0, j1] = near(ys, wall.minY, wall.maxY);
326
+ for (let j = j0; j <= j1; j += 1) {
327
+ for (let i = i0; i <= i1; i += 1) {
328
+ const x = xs[i];
329
+ const y = ys[j];
330
+ if (x > wall.minX && x < wall.maxX && y > wall.minY && y < wall.maxY)
331
+ open[j * width + i] = 0;
332
+ }
333
+ }
334
+ each([i0, i1], [j0, j1], (axis, a, b, key) => {
335
+ if (blocks(a, b, wall))
336
+ closed[axis][key] = 1;
337
+ });
338
+ each(near(xs, wall.minX - clear, wall.maxX + clear), near(ys, wall.minY - clear, wall.maxY + clear), (axis, a, b, key) => {
339
+ crowd[axis][key] = Math.max(crowd[axis][key], crowding(a, b, wall, clear));
340
+ });
341
+ }
342
+ taken.forEach((line, which) => {
343
+ for (let index = 0; index + 1 < line.length; index += 1) {
344
+ const [p, q] = [line[index], line[index + 1]];
345
+ const reach = spacing(which, index, widest);
346
+ const xr = near(xs, Math.min(p.x, q.x) - reach, Math.max(p.x, q.x) + reach);
347
+ const yr = near(ys, Math.min(p.y, q.y) - reach, Math.max(p.y, q.y) + reach);
348
+ each(xr, yr, (axis, a, b, key) => {
349
+ if (runsAlong(a, b, p, q, spacing(which, index, ownAt(a, b))))
350
+ closed[axis][key] = 1;
351
+ if (crosses(a, b, p, q))
352
+ cross[axis][key] = cross[axis][key] + 1;
353
+ });
354
+ }
355
+ });
356
+ return { open, closed, crowd, cross };
357
+ }
358
+ /** The index of the first of the sorted values at least `value`, or their count if none is. */
359
+ function firstAtLeast(values, value) {
360
+ let lo = 0;
361
+ let hi = values.length;
362
+ while (lo < hi) {
363
+ const mid = (lo + hi) >> 1;
364
+ if (values[mid] < value)
365
+ lo = mid + 1;
366
+ else
367
+ hi = mid;
368
+ }
369
+ return lo;
370
+ }
371
+ /** How much of a stretch runs within `clear` of one wall's side, alongside it; 0 if none does. */
372
+ function crowding(a, b, wall, clear) {
273
373
  const across = Math.abs(a.y - b.y) < 0.5;
274
374
  const level = across ? a.y : a.x;
275
375
  const from = across ? Math.min(a.x, b.x) : Math.min(a.y, b.y);
276
376
  const to = across ? Math.max(a.x, b.x) : Math.max(a.y, b.y);
277
- let worst = 0;
278
- for (const wall of walls) {
279
- const [lo, hi] = across ? [wall.minY, wall.maxY] : [wall.minX, wall.maxX];
280
- const [alo, ahi] = across ? [wall.minX, wall.maxX] : [wall.minY, wall.maxY];
281
- const near = (level > lo - clear + 0.01 && level <= lo) || (level >= hi && level < hi + clear - 0.01);
282
- if (!near)
283
- continue;
284
- worst = Math.max(worst, Math.min(to, ahi) - Math.max(from, alo));
285
- }
286
- return worst;
377
+ const [lo, hi] = across ? [wall.minY, wall.maxY] : [wall.minX, wall.maxX];
378
+ const [alo, ahi] = across ? [wall.minX, wall.maxX] : [wall.minY, wall.maxY];
379
+ const near = (level > lo - clear + 0.01 && level <= lo) || (level >= hi && level < hi + clear - 0.01);
380
+ return near ? Math.max(0, Math.min(to, ahi) - Math.max(from, alo)) : 0;
287
381
  }
288
382
  /**
289
- * Whether a stretch runs along a placed line, for any distance, closer than
290
- * the spacing between the two there: a lane, or clear of a text either carries.
383
+ * Whether a stretch runs along one stretch of a placed line, for any distance,
384
+ * closer than `spacing`: a lane, or clear of a text either carries.
291
385
  */
292
- function alongTaken(a, b, taken, spacing, own) {
386
+ function runsAlong(a, b, p, q, spacing) {
293
387
  const across = Math.abs(a.y - b.y) < 0.5;
388
+ if ((Math.abs(p.y - q.y) < 0.5) !== across)
389
+ return false;
294
390
  const level = across ? a.y : a.x;
391
+ const other = across ? p.y : p.x;
392
+ if (Math.abs(other - level) >= spacing - 0.5)
393
+ return false;
295
394
  const lo = across ? Math.min(a.x, b.x) : Math.min(a.y, b.y);
296
395
  const hi = across ? Math.max(a.x, b.x) : Math.max(a.y, b.y);
297
- for (const [which, line] of taken.entries()) {
298
- for (let index = 0; index + 1 < line.length; index += 1) {
299
- const [p, q] = [line[index], line[index + 1]];
300
- const pAcross = Math.abs(p.y - q.y) < 0.5;
301
- if (pAcross !== across)
302
- continue;
303
- const other = across ? p.y : p.x;
304
- if (Math.abs(other - level) >= spacing(which, index, own) - 0.5)
305
- continue;
306
- const pLo = across ? Math.min(p.x, q.x) : Math.min(p.y, q.y);
307
- const pHi = across ? Math.max(p.x, q.x) : Math.max(p.y, q.y);
308
- if (Math.min(hi, pHi) - Math.max(lo, pLo) > 0.5)
309
- return true;
310
- }
311
- }
312
- return false;
396
+ const pLo = across ? Math.min(p.x, q.x) : Math.min(p.y, q.y);
397
+ const pHi = across ? Math.max(p.x, q.x) : Math.max(p.y, q.y);
398
+ return Math.min(hi, pHi) - Math.max(lo, pLo) > 0.5;
313
399
  }
314
- /** How many stretches of placed lines a stretch crosses square on, touching neither end of either. */
315
- function crossings(a, b, taken) {
400
+ /** Whether a stretch crosses one stretch of a placed line square on, touching neither end of either. */
401
+ function crosses(a, b, p, q) {
316
402
  const across = Math.abs(a.y - b.y) < 0.5;
403
+ if ((Math.abs(p.y - q.y) < 0.5) === across)
404
+ return false;
317
405
  const level = across ? a.y : a.x;
318
406
  const lo = across ? Math.min(a.x, b.x) : Math.min(a.y, b.y);
319
407
  const hi = across ? Math.max(a.x, b.x) : Math.max(a.y, b.y);
320
- let found = 0;
321
- for (const line of taken) {
322
- for (let index = 0; index + 1 < line.length; index += 1) {
323
- const [p, q] = [line[index], line[index + 1]];
324
- const pAcross = Math.abs(p.y - q.y) < 0.5;
325
- if (pAcross === across)
326
- continue;
327
- const at = across ? p.x : p.y;
328
- const pLo = across ? Math.min(p.y, q.y) : Math.min(p.x, q.x);
329
- const pHi = across ? Math.max(p.y, q.y) : Math.max(p.x, q.x);
330
- if (at > lo + 0.5 && at < hi - 0.5 && level > pLo + 0.5 && level < pHi - 0.5)
331
- found += 1;
332
- }
333
- }
334
- return found;
408
+ const at = across ? p.x : p.y;
409
+ const pLo = across ? Math.min(p.y, q.y) : Math.min(p.x, q.x);
410
+ const pHi = across ? Math.max(p.y, q.y) : Math.max(p.x, q.x);
411
+ return at > lo + 0.5 && at < hi - 0.5 && level > pLo + 0.5 && level < pHi - 0.5;
335
412
  }
336
413
  /** The points with every one dropped that sits on a straight run between its neighbors. */
337
414
  function straightRuns(points) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {