reladraw 0.12.0 → 0.14.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/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
@@ -11,11 +11,11 @@
11
11
  * straight in, with a straight stretch at each end at least `stub` long, so an
12
12
  * arrowhead always lands on a straight piece. Between those two stretches it
13
13
  * takes the cheapest way through the grid, where cost is length plus a charge
14
- * for every turn. On a right-angled grid many ways tie on length — a staircase
15
- * is exactly as long as one bend — and the turn charge breaks those ties toward
16
- * the fewest bends, which is the line a person would draw. What still ties
17
- * after that goes over the top rather than under, and round the right rather
18
- * than the left.
14
+ * for every turn and for every line already placed that it crosses. On a
15
+ * right-angled grid many ways tie on length — a staircase is exactly as long as
16
+ * one bend — and the turn charge breaks those ties toward the fewest bends,
17
+ * which is the line a person would draw. What still ties after that goes over
18
+ * the top rather than under, and round the right rather than the left.
19
19
  *
20
20
  * A way through a box is not a losing option here. It is not a way at all.
21
21
  */
@@ -57,6 +57,35 @@ export interface SearchOptions {
57
57
  */
58
58
  taken: SearchPoint[][];
59
59
  lane: number;
60
+ /**
61
+ * For each line in `taken`, for each of its stretches, half the depth across
62
+ * it of a text riding there, and 0 where none does. A line running beside a
63
+ * stretch carrying a text keeps clear of the text, not just of the line.
64
+ */
65
+ takenHalves?: number[][];
66
+ /**
67
+ * A gap the line must pass through: somewhere it crosses the line at `at`
68
+ * on the `run` axis, travelling along `run`, at a level between `lo` and `hi`.
69
+ */
70
+ gate?: SearchGate;
71
+ /**
72
+ * Half the depth of this line's own text, which rides in the gate: inside it,
73
+ * the line keeps that much further from the lines beside it.
74
+ */
75
+ ownHalf?: number;
76
+ /**
77
+ * How far the line may go at all: the diagram with room round it. A wall
78
+ * reaching past this — a clause's fence, which runs to the edge of the page —
79
+ * cannot be gone round at its far end.
80
+ */
81
+ bounds?: SearchWall;
82
+ }
83
+ /** A gap a line must pass through. See `SearchOptions.gate`. */
84
+ export interface SearchGate {
85
+ run: 'x' | 'y';
86
+ at: number;
87
+ lo: number;
88
+ hi: number;
60
89
  }
61
90
  export interface SearchResult {
62
91
  points: SearchPoint[];
package/dist/search.js CHANGED
@@ -11,11 +11,11 @@
11
11
  * straight in, with a straight stretch at each end at least `stub` long, so an
12
12
  * arrowhead always lands on a straight piece. Between those two stretches it
13
13
  * takes the cheapest way through the grid, where cost is length plus a charge
14
- * for every turn. On a right-angled grid many ways tie on length — a staircase
15
- * is exactly as long as one bend — and the turn charge breaks those ties toward
16
- * the fewest bends, which is the line a person would draw. What still ties
17
- * after that goes over the top rather than under, and round the right rather
18
- * than the left.
14
+ * for every turn and for every line already placed that it crosses. On a
15
+ * right-angled grid many ways tie on length — a staircase is exactly as long as
16
+ * one bend — and the turn charge breaks those ties toward the fewest bends,
17
+ * which is the line a person would draw. What still ties after that goes over
18
+ * the top rather than under, and round the right rather than the left.
19
19
  *
20
20
  * A way through a box is not a losing option here. It is not a way at all.
21
21
  */
@@ -45,8 +45,25 @@ 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
- const xs = tracks(walls, 'x', clear, [out.x, back.x], options.taken, options.lane);
49
- const ys = tracks(walls, 'y', clear, [out.y, back.y], options.taken, options.lane);
48
+ const bounds = options.bounds;
49
+ const gate = options.gate;
50
+ // How far a stretch keeps from a placed one beside it: a lane, or clear of
51
+ // the text either of them carries there.
52
+ const halves = options.takenHalves ?? [];
53
+ const ownHalf = options.ownHalf ?? 0;
54
+ const inGate = (a, b) => {
55
+ if (!gate)
56
+ return false;
57
+ const along = gate.run === 'x' ? Math.abs(a.y - b.y) < 0.5 : Math.abs(a.x - b.x) < 0.5;
58
+ const level = gate.run === 'x' ? a.y : a.x;
59
+ return along && level > gate.lo && level < gate.hi;
60
+ };
61
+ const spacing = (line, stretch, own) => Math.max(options.lane, (halves[line]?.[stretch] ?? 0) + own);
62
+ const gateTracks = (axis) => !gate ? [] : axis === gate.run ? [gate.at] : [(gate.lo + gate.hi) / 2];
63
+ const xs = tracks(walls, 'x', clear, [out.x, back.x, ...gateTracks('x')], options.taken, spacing, ownHalf)
64
+ .filter((x) => !bounds || (x >= bounds.minX && x <= bounds.maxX) || x === out.x || x === back.x);
65
+ const ys = tracks(walls, 'y', clear, [out.y, back.y, ...gateTracks('y')], options.taken, spacing, ownHalf)
66
+ .filter((y) => !bounds || (y >= bounds.minY && y <= bounds.maxY) || y === out.y || y === back.y);
50
67
  const ix = new Map(xs.map((x, index) => [x, index]));
51
68
  const iy = new Map(ys.map((y, index) => [y, index]));
52
69
  const width = xs.length;
@@ -69,14 +86,17 @@ export function searchRoute(start, end, walls, options) {
69
86
  const tj = iy.get(back.y);
70
87
  // Cost is compared first on length and turns, then on a tie-break that
71
88
  // prefers a stretch higher up the page, or further right, so that of two
72
- // ways round that are exactly as long the line goes over the top.
73
- const size = width * height * 4;
89
+ // ways round that are exactly as long the line goes over the top. A state
90
+ // also records whether the line has passed its gate yet; it may only finish
91
+ // once it has.
92
+ const size = width * height * 8;
74
93
  const cost = new Float64Array(size).fill(Infinity);
75
94
  const tie = new Float64Array(size).fill(Infinity);
76
95
  const from = new Int32Array(size).fill(-1);
77
- const state = (i, j, h) => (j * width + i) * 4 + h;
96
+ const state = (i, j, h, p) => ((j * width + i) * 4 + h) * 2 + p;
97
+ const done = gate ? 1 : 0;
78
98
  const heap = new MinHeap();
79
- const begin = state(si, sj, first);
99
+ const begin = state(si, sj, first, 0);
80
100
  cost[begin] = 0;
81
101
  tie[begin] = 0;
82
102
  heap.push(begin, 0, 0);
@@ -85,11 +105,13 @@ export function searchRoute(start, end, walls, options) {
85
105
  const [current, c, t] = heap.pop();
86
106
  if (c > cost[current] || (c === cost[current] && t > tie[current]))
87
107
  continue;
88
- const h = current % 4;
89
- const cell = (current - h) / 4;
108
+ const p = current % 2;
109
+ const rest = (current - p) / 2;
110
+ const h = rest % 4;
111
+ const cell = (rest - h) / 4;
90
112
  const i = cell % width;
91
113
  const j = (cell - i) / width;
92
- if (i === ti && j === tj && h === last) {
114
+ if (i === ti && j === tj && h === last && p === done) {
93
115
  goal = current;
94
116
  break;
95
117
  }
@@ -99,7 +121,7 @@ export function searchRoute(start, end, walls, options) {
99
121
  continue;
100
122
  if (next !== h) {
101
123
  // Turning on the spot costs a turn and moves nowhere.
102
- relax(current, state(i, j, next), c + turn, t);
124
+ relax(current, state(i, j, next, p), c + turn, t);
103
125
  continue;
104
126
  }
105
127
  const ni = i + STEP[next][0];
@@ -108,16 +130,25 @@ export function searchRoute(start, end, walls, options) {
108
130
  continue;
109
131
  const a = { x: xs[i], y: ys[j] };
110
132
  const b = { x: xs[ni], y: ys[nj] };
111
- if (blocked(a, b, walls) || alongTaken(a, b, options.taken, options.lane))
133
+ const own = inGate(a, b) ? ownHalf : 0;
134
+ if (blocked(a, b, walls) || alongTaken(a, b, options.taken, spacing, own))
112
135
  continue;
136
+ // Passing the gate: a stretch through it, reaching the line at `at`.
137
+ const through = inGate(a, b) &&
138
+ Math.min(gate.run === 'x' ? a.x : a.y, gate.run === 'x' ? b.x : b.y) <= gate.at + 0.01 &&
139
+ Math.max(gate.run === 'x' ? a.x : a.y, gate.run === 'x' ? b.x : b.y) >= gate.at - 0.01;
140
+ const np = p === 1 || through ? 1 : 0;
113
141
  // Running closer to a wall than `clear` counts double: the line may pass
114
- // a narrow gap, but where there is room it keeps its distance.
115
- const length = Math.abs(b.x - a.x) + Math.abs(b.y - a.y) + crowding(a, b, walls, clear);
142
+ // a narrow gap, but where there is room it keeps its distance. Crossing a
143
+ // line already placed costs what a turn does, so of two ways much the
144
+ // 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);
116
147
  // Higher up for a stretch across, further right for one down: a smaller
117
148
  // tie-break is preferred, so the across level counts as it is and the
118
149
  // down level counts negated.
119
150
  const lean = next % 2 === 0 ? a.y * length : -a.x * length;
120
- relax(current, state(ni, nj, next), c + length, t + lean);
151
+ relax(current, state(ni, nj, next, np), c + length, t + lean);
121
152
  }
122
153
  }
123
154
  if (goal < 0)
@@ -132,7 +163,8 @@ export function searchRoute(start, end, walls, options) {
132
163
  }
133
164
  const path = [];
134
165
  for (let at = goal; at >= 0; at = from[at]) {
135
- const cell = (at - (at % 4)) / 4;
166
+ const rest = (at - (at % 2)) / 2;
167
+ const cell = (rest - (rest % 4)) / 4;
136
168
  const i = cell % width;
137
169
  const j = (cell - i) / width;
138
170
  const point = { x: xs[i], y: ys[j] };
@@ -146,9 +178,10 @@ export function searchRoute(start, end, walls, options) {
146
178
  /**
147
179
  * The tracks on one axis: beside each wall at `clear` where that is open,
148
180
  * down the middle of every gap between walls, through the ends, and a lane
149
- * either side of each line already placed.
181
+ * either side of each line already placed — a wider one where a text rides,
182
+ * and wider again by `own` for this line's own text.
150
183
  */
151
- function tracks(walls, axis, clear, ends, taken, lane) {
184
+ function tracks(walls, axis, clear, ends, taken, spacing, own) {
152
185
  const lo = (wall) => (axis === 'x' ? wall.minX : wall.minY);
153
186
  const hi = (wall) => (axis === 'x' ? wall.maxX : wall.maxY);
154
187
  // The other axis, to tell a real gap — two walls facing each other across
@@ -166,7 +199,7 @@ function tracks(walls, axis, clear, ends, taken, lane) {
166
199
  found.add((hi(a) + lo(b)) / 2);
167
200
  }
168
201
  }
169
- for (const line of taken) {
202
+ taken.forEach((line, which) => {
170
203
  for (let index = 0; index + 1 < line.length; index += 1) {
171
204
  const [a, b] = [line[index], line[index + 1]];
172
205
  // A stretch across sits at a level on y; one down, at a level on x.
@@ -176,10 +209,12 @@ function tracks(walls, axis, clear, ends, taken, lane) {
176
209
  // A lane either side of a placed line. One that runs close beside a box
177
210
  // is paid for as crowding, like any other track.
178
211
  const level = axis === 'y' ? a.y : a.x;
179
- found.add(level - lane);
180
- found.add(level + lane);
212
+ for (const by of new Set([spacing(which, index, 0), spacing(which, index, own)])) {
213
+ found.add(level - by);
214
+ found.add(level + by);
215
+ }
181
216
  }
182
- }
217
+ });
183
218
  return [...new Set([...found].map(snap))].sort((p, q) => p - q);
184
219
  }
185
220
  /** Coordinates to a hundredth, so a track and the end it runs through compare equal. */
@@ -250,20 +285,23 @@ function crowding(a, b, walls, clear) {
250
285
  }
251
286
  return worst;
252
287
  }
253
- /** Whether a stretch runs along a placed line, closer than a lane, for any distance. */
254
- function alongTaken(a, b, taken, lane) {
288
+ /**
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.
291
+ */
292
+ function alongTaken(a, b, taken, spacing, own) {
255
293
  const across = Math.abs(a.y - b.y) < 0.5;
256
294
  const level = across ? a.y : a.x;
257
295
  const lo = across ? Math.min(a.x, b.x) : Math.min(a.y, b.y);
258
296
  const hi = across ? Math.max(a.x, b.x) : Math.max(a.y, b.y);
259
- for (const line of taken) {
297
+ for (const [which, line] of taken.entries()) {
260
298
  for (let index = 0; index + 1 < line.length; index += 1) {
261
299
  const [p, q] = [line[index], line[index + 1]];
262
300
  const pAcross = Math.abs(p.y - q.y) < 0.5;
263
301
  if (pAcross !== across)
264
302
  continue;
265
303
  const other = across ? p.y : p.x;
266
- if (Math.abs(other - level) >= lane - 0.5)
304
+ if (Math.abs(other - level) >= spacing(which, index, own) - 0.5)
267
305
  continue;
268
306
  const pLo = across ? Math.min(p.x, q.x) : Math.min(p.y, q.y);
269
307
  const pHi = across ? Math.max(p.x, q.x) : Math.max(p.y, q.y);
@@ -273,6 +311,28 @@ function alongTaken(a, b, taken, lane) {
273
311
  }
274
312
  return false;
275
313
  }
314
+ /** How many stretches of placed lines a stretch crosses square on, touching neither end of either. */
315
+ function crossings(a, b, taken) {
316
+ const across = Math.abs(a.y - b.y) < 0.5;
317
+ const level = across ? a.y : a.x;
318
+ const lo = across ? Math.min(a.x, b.x) : Math.min(a.y, b.y);
319
+ 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;
335
+ }
276
336
  /** The points with every one dropped that sits on a straight run between its neighbors. */
277
337
  function straightRuns(points) {
278
338
  const kept = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {