reladraw 0.14.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.14.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.14.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
 
@@ -1112,6 +1112,13 @@ That one was found by testing the lexer, not by rendering — and it could not h
1112
1112
 
1113
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.
1114
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
+
1115
1122
  **0.14.0**
1116
1123
 
1117
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.
package/dist/render.js CHANGED
@@ -1850,15 +1850,21 @@ function planWays(edges, nodes, ends, corridors, measurer, fontSize) {
1850
1850
  // Which line takes the inside of a shared stretch depends on which was placed
1851
1851
  // first, and the wrong order makes two lines cross where they need not. So a
1852
1852
  // pair that crosses is placed again the other way round, and kept that way if
1853
- // 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.
1854
1858
  const crossingsOf = (edge) => order.reduce((sum, other) => (other === edge ? sum : sum + linesCross(found.get(edge).points, found.get(other).points)), 0);
1855
- 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) {
1856
1861
  let better = false;
1857
- for (let i = 0; i < order.length; i += 1) {
1858
- 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) {
1859
1864
  const [first, second] = [order[i], order[j]];
1860
1865
  if (linesCross(found.get(first).points, found.get(second).points) === 0)
1861
1866
  continue;
1867
+ tries -= 1;
1862
1868
  const before = crossingsOf(first) + crossingsOf(second);
1863
1869
  const kept = [found.get(first), found.get(second)];
1864
1870
  const rest = order.filter((edge) => edge !== first && edge !== second);
@@ -2196,6 +2202,8 @@ function textOnWay(points, edge, nodes, measurer, fontSize) {
2196
2202
  }
2197
2203
  /** How much a turn costs the search, in the same units as length. */
2198
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;
2199
2207
  /**
2200
2208
  * The searched way for one edge, or one half of a `between` edge. A named side
2201
2209
  * is fixed; an end with no side named may leave or arrive by any of its box's
@@ -2302,6 +2310,9 @@ function searchWay(edge, nodes, start, startNode, end, endNode, taken, lane, use
2302
2310
  taken,
2303
2311
  lane,
2304
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,
2305
2316
  bounds: wallOf(grow(nodes.map((node) => extentOfBox(faceOf(node))).reduce(union), ROUTE_STUB * 4)),
2306
2317
  });
2307
2318
  if (found && (!best || found.cost < best.cost - 1e-6))
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.14.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": {