reladraw 0.4.0 → 0.6.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/render.js CHANGED
@@ -1,42 +1,27 @@
1
- import { ARROW_MARKER_WIDTH, ATTACH_MARGIN, ATTACH_STEP, DECK_STEP, DEFAULT_FONT_SIZE, ICON_LINES, LINE_WIDTH, PAD, fontSizeFor, textExtent, textStyleFor, widestLine, } from './constants.js';
1
+ import { ARROW_LENGTH, ARROW_MARKER_WIDTH, ATTACH_MARGIN, ATTACH_STEP, DECK_STEP, DEFAULT_FONT_SIZE, ICON_LINES, LINE_WIDTH, PAD, SEPARATION_GAP, fontSizeFor, textExtent, textStyleFor, widestLine, } from './constants.js';
2
2
  import { describeAxis } from './ast.js';
3
3
  import { SourceError } from './errors.js';
4
4
  import { ICON_STROKE } from './icons.js';
5
5
  import { monospaceMeasurer } from './measure.js';
6
+ import { DARK_THEME, THEMES } from './themes.js';
6
7
  import { plain } from './text.js';
7
- /**
8
- * Sampled out of `examples/reference/arch.png` rather than invented,
9
- * so the benchmark render and the drawing it is measured against differ by
10
- * geometry and typography alone. A container is a shade off the page and barely
11
- * outlined; a leaf is the navy that carries the diagram's weight.
12
- */
13
- export const DARK_THEME = {
14
- background: '#111111',
15
- boxFill: '#191728',
16
- boxStroke: '#4f5367',
17
- containerFill: '#191920',
18
- containerStroke: '#25242f',
19
- text: '#d9d9d9',
20
- mutedText: '#8b8b8b',
21
- edge: '#5c5c7c',
22
- // Both sampled off the reference's machine glyphs. Note that the reference
23
- // gives each icon its own hue — the drive is gray, the laptop periwinkle, the
24
- // workstation violet — which is a drawing tool's per-shape default and not a
25
- // system. One pair for the whole set is the deliberate difference: an icon
26
- // should read as part of the diagram's palette, not as clip art dropped in.
27
- iconInk: '#8d8d8e',
28
- iconShade: '#3e3d58',
29
- };
30
8
  const CORNER = 8;
31
9
  /** Turn solved geometry into a standalone SVG document. */
32
10
  export function render(layout, options = {}) {
33
11
  const measurer = options.measurer ?? monospaceMeasurer();
34
12
  const fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
35
- // `diagram background:` is the author overruling the theme for this one
36
- // drawing, so it is folded in here and everything downstream sees one theme.
37
- const base = options.theme ?? DARK_THEME;
38
- const stated = layout.diagram['background'];
39
- const theme = stated === undefined ? base : { ...base, background: stated };
13
+ // A theme passed in — the command line's `--theme` — beats the one the file
14
+ // names, so one source renders in either. `diagram background:` and `text:`
15
+ // are the author overruling a color of whichever theme that is, and a color
16
+ // written by hand wins over any theme, so they are folded in afterwards and
17
+ // everything downstream sees one theme.
18
+ const named = layout.diagram['theme'];
19
+ const base = options.theme ?? (named === undefined ? undefined : THEMES[named]) ?? DARK_THEME;
20
+ const theme = {
21
+ ...base,
22
+ ...(layout.diagram['background'] !== undefined && { background: layout.diagram['background'] }),
23
+ ...(layout.diagram['text.color'] !== undefined && { text: layout.diagram['text.color'] }),
24
+ };
40
25
  const body = [];
41
26
  for (const root of layout.roots) {
42
27
  body.push(drawNode(root, theme, measurer, fontSize, layout.markup));
@@ -49,9 +34,11 @@ export function render(layout, options = {}) {
49
34
  // is ordered by where its ends turned out to be.
50
35
  const ends = planEndpoints(layout.edges, measurer, fontSize);
51
36
  const corridors = planCorridors(layout.edges, ends, measurer, fontSize);
37
+ const routes = planRoutes(layout.edges, layout.nodes, ends, measurer, fontSize);
38
+ planLoops(layout.edges, layout.nodes, ends, corridors, routes, measurer, fontSize);
52
39
  aimFreeEnds(layout.edges, ends, corridors);
53
40
  for (const edge of layout.edges) {
54
- const drawn = drawEdge(edge, ends.get(edge), corridors.get(edge), theme, measurer, fontSize, layout.markup);
41
+ const drawn = drawEdge(edge, ends.get(edge), corridors.get(edge), routes.get(edge), theme, measurer, fontSize, layout.markup);
55
42
  body.push(drawn.svg);
56
43
  ink = union(ink, grow(drawn.ink, layout.margin));
57
44
  }
@@ -268,7 +255,7 @@ function drawIcon(icon, x, y, side, theme) {
268
255
  ].join('\n');
269
256
  }
270
257
  // --- edges -------------------------------------------------------------------
271
- function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
258
+ function drawEdge(edge, ends, corridor, route, theme, measurer, fontSize, markup) {
272
259
  const { start, end } = ends;
273
260
  const color = lineOf(edge.appearance, theme.edge);
274
261
  const markerEnd = ` marker-end="url(#${markerId(color)})"`;
@@ -284,7 +271,13 @@ function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
284
271
  let ink = extentOfPoints([start, end]);
285
272
  let midX;
286
273
  let midY;
287
- if (corridor) {
274
+ if (route) {
275
+ parts.push(` <path d="${roundedPath(route.points)}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
276
+ ink = union(ink, extentOfPoints(route.points));
277
+ midX = route.mid.x;
278
+ midY = route.mid.y;
279
+ }
280
+ else if (corridor) {
288
281
  const path = corridorPath(start, end, corridor);
289
282
  ink = union(ink, path.ink);
290
283
  parts.push(` <path d="${path.d}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
@@ -1018,6 +1011,775 @@ function planCorridors(edges, ends, measurer, fontSize) {
1018
1011
  }
1019
1012
  return plans;
1020
1013
  }
1014
+ /**
1015
+ * Route every edge that has to go around its own two boxes.
1016
+ *
1017
+ * An edge leaving the left of one box for the right of another, with the second
1018
+ * box further right, has to turn back on itself, and a single curve can only
1019
+ * do that by crossing its own boxes. In a row it flattened into a straight line
1020
+ * through both; stepped down, even by a `normal` gap, it still doubled back
1021
+ * across the first box. So such an edge runs along a channel instead, turning
1022
+ * back at each end: in the gap between its two boxes if that holds the line
1023
+ * and its text, and otherwise over the top of everything between its ends,
1024
+ * with its text on the top, where it cannot land on a box.
1025
+ *
1026
+ * Over the top, always, and round the right for a column. Nothing here weighs
1027
+ * one way round against the other: the shorter way ties in the case that
1028
+ * seemed to argue for it, and a default that flips on one box's height is
1029
+ * harder to predict than one that never does. The run is placed the way a
1030
+ * `between` channel is, measured off where the boxes landed.
1031
+ */
1032
+ function planLoops(edges, nodes, ends, corridors, routes, measurer, fontSize) {
1033
+ // Loops over the top, gathered so that two sharing a stretch can take a lane
1034
+ // each rather than drawing on top of one another.
1035
+ const tops = [];
1036
+ for (const edge of edges) {
1037
+ if (corridors.has(edge) || edge.passes)
1038
+ continue;
1039
+ const { start, end } = ends.get(edge);
1040
+ if (start.side === undefined || end.side === undefined)
1041
+ continue;
1042
+ // Sides at right angles, one facing away from the other end.
1043
+ if (start.tx * end.tx + start.ty * end.ty === 0) {
1044
+ const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
1045
+ const plan = turnBack(edge, start, end, inWay, routes, measurer, fontSize);
1046
+ if (plan)
1047
+ tops.push(plan);
1048
+ continue;
1049
+ }
1050
+ // The two ends point opposite ways along one axis, each away from the other.
1051
+ if (start.tx !== -end.tx || start.ty !== -end.ty)
1052
+ continue;
1053
+ const run = start.tx !== 0 ? 'x' : 'y';
1054
+ const across = run === 'x' ? 'y' : 'x';
1055
+ const outward = run === 'x' ? start.tx : start.ty;
1056
+ if (outward * (end[run] - start[run]) >= 0)
1057
+ continue;
1058
+ const a = faceOf(edge.from);
1059
+ const b = faceOf(edge.to);
1060
+ // Far enough out that the line reads as passing, and that half the text,
1061
+ // centered on the line, still clears the box beside it.
1062
+ const clear = Math.max(SEPARATION_GAP, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
1063
+ const from = Math.min(lo(a, run), lo(b, run));
1064
+ const to = Math.max(hi(a, run), hi(b, run));
1065
+ const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
1066
+ const blocked = (low, high) => inWay.some((node) => {
1067
+ const face = faceOf(node);
1068
+ return (lo(face, run) < to && hi(face, run) > from &&
1069
+ lo(face, across) < high && hi(face, across) > low);
1070
+ });
1071
+ // Boxes apart across the axis have a gap between them, and a line that
1072
+ // fits in it turns back through that — the shortest way, and the one a
1073
+ // single curve was reaching for.
1074
+ const [upper, lower] = lo(a, across) <= lo(b, across) ? [a, b] : [b, a];
1075
+ const gap = { lo: hi(upper, across), hi: lo(lower, across) };
1076
+ const middle = (gap.lo + gap.hi) / 2;
1077
+ if (gap.hi - gap.lo >= clear * 2 && !blocked(middle - clear, middle + clear)) {
1078
+ corridors.set(edge, {
1079
+ axis: across,
1080
+ lane: middle,
1081
+ enter: start[run],
1082
+ leave: end[run],
1083
+ loop: true,
1084
+ });
1085
+ continue;
1086
+ }
1087
+ // Otherwise over the top. Measured as distance outward — up for a row,
1088
+ // right for a column — so one loop serves both.
1089
+ const sign = across === 'y' ? -1 : 1;
1090
+ const out = (box) => {
1091
+ const [p, q] = [sign * lo(box, across), sign * hi(box, across)];
1092
+ return [Math.min(p, q), Math.max(p, q)];
1093
+ };
1094
+ const inner = Math.min(sign * start[across], sign * end[across]);
1095
+ // A box that shares the stretch and reaches into the band the line needs
1096
+ // pushes the line out past it, which can bring another into the band.
1097
+ const pushOut = (start) => {
1098
+ let lane = start;
1099
+ for (let moved = true; moved;) {
1100
+ moved = false;
1101
+ for (const node of inWay) {
1102
+ const face = faceOf(node);
1103
+ if (lo(face, run) >= to || hi(face, run) <= from)
1104
+ continue;
1105
+ const [near, far] = out(face);
1106
+ if (far + clear > lane && near < lane + clear && far > inner) {
1107
+ lane = far + clear;
1108
+ moved = true;
1109
+ }
1110
+ }
1111
+ }
1112
+ return lane;
1113
+ };
1114
+ tops.push({
1115
+ edge,
1116
+ across,
1117
+ sign,
1118
+ lane: pushOut(Math.max(out(a)[1], out(b)[1]) + clear),
1119
+ loop: true,
1120
+ from,
1121
+ to,
1122
+ height: sign * start[across] + sign * end[across],
1123
+ pushOut,
1124
+ place: (lane) => corridors.set(edge, { axis: across, lane: sign * lane, enter: start[run], leave: end[run], loop: true }),
1125
+ });
1126
+ }
1127
+ // Lines over one stretch nest so they do not cross. A turn-back goes inside
1128
+ // any loop, because it comes in to a box from the side the loops pass over;
1129
+ // otherwise the shorter goes inside, and of two the same length, the one
1130
+ // whose ends sit further out. Each lane is as far from the one inside it as
1131
+ // the lanes of a named gap are.
1132
+ tops.sort((p, q) => Number(p.loop) - Number(q.loop) || p.to - p.from - (q.to - q.from) || q.height - p.height);
1133
+ const placed = [];
1134
+ for (const top of tops) {
1135
+ for (let moved = true; moved;) {
1136
+ moved = false;
1137
+ for (const other of placed) {
1138
+ if (other.across !== top.across || other.sign !== top.sign)
1139
+ continue;
1140
+ if (other.from >= top.to || top.from >= other.to)
1141
+ continue;
1142
+ const step = Math.max(ATTACH_STEP, laneExtent(top.edge, top.across, measurer, fontSize), laneExtent(other.edge, other.across, measurer, fontSize));
1143
+ if (Math.abs(top.lane - other.lane) < step - 0.5) {
1144
+ top.lane = top.pushOut(other.lane + step);
1145
+ moved = true;
1146
+ }
1147
+ }
1148
+ }
1149
+ placed.push(top);
1150
+ top.place(top.lane);
1151
+ }
1152
+ }
1153
+ /**
1154
+ * A route for an edge whose named sides are at right angles and one of which
1155
+ * faces away from the other end: `from: left to: top` with the far node to
1156
+ * the right. A single curve leaving that side can only turn back across its
1157
+ * own box. So the line steps out of the side facing away, goes out past both
1158
+ * boxes the way the other side faces, runs along there, and comes straight in
1159
+ * to the other side. Undefined when neither side faces away, where the curve
1160
+ * already reads right. The route is recorded in `routes` once its lane is
1161
+ * settled against any other line running outside the same boxes.
1162
+ */
1163
+ function turnBack(edge, start, end, inWay, routes, measurer, fontSize) {
1164
+ const facesAway = (from, to) => from.tx * (to.x - from.x) + from.ty * (to.y - from.y) < 0;
1165
+ const startAway = facesAway(start, end);
1166
+ if (!startAway && !facesAway(end, start))
1167
+ return undefined;
1168
+ // Planned from the end facing away, and turned round if that is the far end.
1169
+ const [away, other] = startAway ? [start, end] : [end, start];
1170
+ const [awayBox, otherBox] = (startAway ? [edge.from, edge.to] : [edge.to, edge.from]).map(faceOf);
1171
+ const run = away.tx !== 0 ? 'x' : 'y';
1172
+ const across = run === 'x' ? 'y' : 'x';
1173
+ const make = axesAcross(across).make;
1174
+ // The way the other side faces, which is the way the line goes out.
1175
+ const sign = across === 'y' ? other.ty : other.tx;
1176
+ const clear = Math.max(SEPARATION_GAP, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
1177
+ const stub = away[run] + (run === 'x' ? away.tx : away.ty) * ROUTE_STUB;
1178
+ const from = Math.min(stub, other[run], lo(awayBox, run), lo(otherBox, run));
1179
+ const to = Math.max(stub, other[run], hi(awayBox, run), hi(otherBox, run));
1180
+ // Measured as distance outward, the way the line goes.
1181
+ const out = (box) => {
1182
+ const [p, q] = [sign * lo(box, across), sign * hi(box, across)];
1183
+ return [Math.min(p, q), Math.max(p, q)];
1184
+ };
1185
+ const inner = Math.min(sign * away[across], sign * other[across]);
1186
+ const pushOut = (start) => {
1187
+ let level = start;
1188
+ for (let moved = true; moved;) {
1189
+ moved = false;
1190
+ for (const node of inWay) {
1191
+ const face = faceOf(node);
1192
+ if (lo(face, run) >= to || hi(face, run) <= from)
1193
+ continue;
1194
+ const [near, far] = out(face);
1195
+ if (far + clear > level && near < level + clear && far > inner) {
1196
+ level = far + clear;
1197
+ moved = true;
1198
+ }
1199
+ }
1200
+ }
1201
+ return level;
1202
+ };
1203
+ return {
1204
+ edge,
1205
+ across,
1206
+ sign,
1207
+ lane: pushOut(Math.max(out(awayBox)[1], out(otherBox)[1]) + clear),
1208
+ loop: false,
1209
+ from,
1210
+ to,
1211
+ height: sign * away[across] + sign * other[across],
1212
+ pushOut,
1213
+ place: (lane) => {
1214
+ const at = sign * lane;
1215
+ // A shallow turn steps out no further than half its depth, the half
1216
+ // circle a loop's turn makes, so it stays inside any loop turning
1217
+ // round the same box.
1218
+ const outward = run === 'x' ? away.tx : away.ty;
1219
+ const step = away[run] + outward * Math.min(ROUTE_STUB, Math.abs(at - away[across]) / 2);
1220
+ const points = tidyRoute([away, make(step, away[across]), make(step, at), make(other[run], at), other]);
1221
+ // The text rides on the longest piece, which is the run outside the
1222
+ // boxes unless the two ends are nearly level.
1223
+ let mid = make((stub + other[run]) / 2, at);
1224
+ let best = -1;
1225
+ for (let index = 0; index + 1 < points.length; index += 1) {
1226
+ const [p, q] = [points[index], points[index + 1]];
1227
+ const length = Math.hypot(q.x - p.x, q.y - p.y);
1228
+ if (length > best) {
1229
+ best = length;
1230
+ mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
1231
+ }
1232
+ }
1233
+ routes.set(edge, { points: startAway ? points : points.reverse(), mid });
1234
+ },
1235
+ };
1236
+ }
1237
+ /** The most a route's corner is rounded by. */
1238
+ const ROUTE_RADIUS = 20;
1239
+ /**
1240
+ * How far a route leaves its side before its first turn: a full corner, and the
1241
+ * arrowhead's length on top so the head lands on a straight piece of line.
1242
+ */
1243
+ const ROUTE_STUB = ROUTE_RADIUS + ARROW_LENGTH;
1244
+ /** The narrowest gap a route will cross over in, between two nodes it passes on opposite sides. */
1245
+ const CROSSING_ROOM = ATTACH_MARGIN * 2;
1246
+ function axesAcross(across) {
1247
+ return across === 'y'
1248
+ ? { across, run: 'x', make: (along, level) => ({ x: along, y: level }) }
1249
+ : { across, run: 'y', make: (along, level) => ({ x: level, y: along }) };
1250
+ }
1251
+ /**
1252
+ * Route every edge that says which side of something it passes.
1253
+ *
1254
+ * `below resolver` means that where the line passes Resolver it is below it —
1255
+ * not that the whole line is. So the line is a run of straight stretches along
1256
+ * the way it travels, and each clause binds only the stretch lying alongside
1257
+ * its nodes. A clause naming several nodes binds the stretch alongside all of
1258
+ * them, which is how `below a and b` says "with no rising in between" and two
1259
+ * separate clauses do not. Consecutive stretches that can share one level do;
1260
+ * where they cannot, the line crosses over in the gap between the two sets of
1261
+ * nodes, and if there is no gap, the file is refused rather than drawn through
1262
+ * a box. No clause says an order: the line meets the nodes in the order they
1263
+ * sit along its way.
1264
+ *
1265
+ * Above and below are passed travelling across the page, left and right
1266
+ * travelling up or down it, so an edge naming both kinds turns between such
1267
+ * runs, as few times as keeps every clause. Which comes first is the named
1268
+ * side's to say, and across when no end names one.
1269
+ *
1270
+ * Nothing here moves a node or looks for a path. Every level is read off where
1271
+ * the named nodes landed, as a `between` channel is, and the only other nodes
1272
+ * consulted are ones sitting on a stretch, which push it further the way its
1273
+ * clause already points.
1274
+ */
1275
+ function planRoutes(edges, nodes, ends, measurer, fontSize) {
1276
+ const routes = new Map();
1277
+ for (const edge of edges) {
1278
+ if (edge.passes)
1279
+ routes.set(edge, planRoute(edge, edge.passes, nodes, ends.get(edge), measurer, fontSize));
1280
+ }
1281
+ return routes;
1282
+ }
1283
+ function planRoute(edge, passes, nodes, { start, end }, measurer, fontSize) {
1284
+ const subject = `edge ${edge.from.name} -> ${edge.to.name}`;
1285
+ const a = faceOf(edge.from);
1286
+ const b = faceOf(edge.to);
1287
+ const named = (anchor, face) => anchor.side === undefined ? centerOf(face) : anchor;
1288
+ // Far enough out that the line reads as passing. The one stretch carrying
1289
+ // the text is held further out, so that half the text, centered on the line,
1290
+ // still clears the box beside it.
1291
+ const clear = SEPARATION_GAP;
1292
+ const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
1293
+ // Above and below are passed travelling across the page, left and right
1294
+ // travelling up or down it. An edge naming only one kind is one section.
1295
+ const acrossPasses = passes.filter((pass) => sideAxis(pass) === 'y');
1296
+ const downPasses = passes.filter((pass) => sideAxis(pass) === 'x');
1297
+ if (acrossPasses.length === 0 || downPasses.length === 0) {
1298
+ const axes = axesAcross(sideAxis(passes[0]));
1299
+ const travel = Math.sign(named(end, b)[axes.run] - named(start, a)[axes.run]) || 1;
1300
+ const section = cut(axes, passes, reach(start, a, axes), reach(end, b, axes), travel);
1301
+ const carrier = longest(section.stretches);
1302
+ settle(section, (centerOf(a)[axes.across] + centerOf(b)[axes.across]) / 2, carrier);
1303
+ return finish([
1304
+ ...approach(start, a, axes, section.stretches[0].level, travel),
1305
+ ...crossings(section),
1306
+ ...approach(end, b, axes, section.stretches[section.stretches.length - 1].level, -travel).reverse(),
1307
+ ], carrier, axes);
1308
+ }
1309
+ // Both kinds: the line turns between travelling across the page and
1310
+ // travelling down it, as often as its clauses need. A side named at an end
1311
+ // says which it does first; with none named, it goes across first. The
1312
+ // fewest turns that keep every clause win, and at each count the other
1313
+ // order is tried before adding a turn. If every attempt fails the same way,
1314
+ // that is the error; if they fail differently, no one of them is the reason
1315
+ // and the error names every clause.
1316
+ const sideways = (anchor) => anchor.side === undefined ? undefined : anchor.side === 'left' || anchor.side === 'right';
1317
+ const acrossFirst = sideways(start) ?? !(sideways(end) ?? false);
1318
+ const refusals = [];
1319
+ for (const count of [2, 3, 4]) {
1320
+ for (const order of [acrossFirst, !acrossFirst]) {
1321
+ try {
1322
+ return turned(order, count);
1323
+ }
1324
+ catch (error) {
1325
+ if (!(error instanceof SourceError))
1326
+ throw error;
1327
+ refusals.push(error);
1328
+ }
1329
+ }
1330
+ }
1331
+ if (refusals.every((refusal) => refusal.message === refusals[0].message))
1332
+ throw refusals[0];
1333
+ const written = passes.map((pass) => `"${pass.written}"`);
1334
+ throw new SourceError(`${subject}: no line keeps ${written.slice(0, -1).join(', ')} and ${written[written.length - 1]} ` +
1335
+ 'all at once, whichever way it turns — each way misses one of those nodes or runs into one. ' +
1336
+ 'Give the nodes more room, or drop a clause', edge.line);
1337
+ /**
1338
+ * A route in `count` sections, alternating across and down, joined at
1339
+ * corners. Each section is planned as an edge naming one kind is, running
1340
+ * from the level of the section before it to the level of the one after,
1341
+ * and each clause binds every section of its kind that passes its node.
1342
+ * The first and last keep as near their own ends as their clauses allow,
1343
+ * so with nothing in the way two sections make an L through the corner
1344
+ * level with both ends; a section between hugs its own clauses. Where the
1345
+ * corners land depends on every section, so they are planned in turn
1346
+ * until none moves.
1347
+ */
1348
+ function turned(acrossFirst, count) {
1349
+ const axes = Array.from({ length: count }, (_, index) => axesAcross((index % 2 === 0) === acrossFirst ? 'y' : 'x'));
1350
+ const last = count - 1;
1351
+ const targets = axes.map(({ across }, index) => index === 0
1352
+ ? centerOf(a)[across]
1353
+ : index === last
1354
+ ? centerOf(b)[across]
1355
+ : (centerOf(a)[across] + centerOf(b)[across]) / 2);
1356
+ // Each section's first and last level, which bound its neighbours' runs.
1357
+ const firstLevels = [...targets];
1358
+ const lastLevels = [...targets];
1359
+ const startAt = reach(start, a, axes[0]);
1360
+ const endAt = reach(end, b, axes[last]);
1361
+ // A section starts or ends at the middle of its end's own node, which
1362
+ // must not push it: the line leaves that node from the side facing it.
1363
+ const obstacles = inWay.filter((node) => !contains(node, edge.from) && !contains(node, edge.to));
1364
+ const near = new Set(passes.flatMap((pass) => pass.nodes));
1365
+ // Which stretch carries the text: its section and index, from the last round.
1366
+ let carries;
1367
+ let sections = [];
1368
+ let travels = [];
1369
+ let bound = new Set();
1370
+ for (let round = 0; round < 8; round += 1) {
1371
+ let moved = false;
1372
+ sections = [];
1373
+ travels = [];
1374
+ bound = new Set();
1375
+ axes.forEach((section, index) => {
1376
+ const from = index === 0 ? startAt : lastLevels[index - 1];
1377
+ const to = index === last ? endAt : firstLevels[index + 1];
1378
+ const travel = Math.sign((index === last ? named(end, b)[section.run] : to) -
1379
+ (index === 0 ? named(start, a)[section.run] : from)) || 1;
1380
+ const low = Math.min(from, to);
1381
+ const high = Math.max(from, to);
1382
+ const mine = passes.filter((pass) => {
1383
+ const box = boundingBox(pass.nodes.map(faceOf));
1384
+ return sideAxis(pass) === section.across && hi(box, section.run) > low && lo(box, section.run) < high;
1385
+ });
1386
+ mine.forEach((pass) => bound.add(pass));
1387
+ const planned = cut(section, mine, from, to, travel);
1388
+ const carrier = carries?.[0] === index ? planned.stretches[carries[1]] : undefined;
1389
+ settle(planned, targets[index], carrier, obstacles, index > 0 && index < last, near);
1390
+ const first = planned.stretches[0].level;
1391
+ const final = planned.stretches[planned.stretches.length - 1].level;
1392
+ if (Math.abs(first - firstLevels[index]) >= 0.5 || Math.abs(final - lastLevels[index]) >= 0.5) {
1393
+ moved = true;
1394
+ }
1395
+ firstLevels[index] = first;
1396
+ lastLevels[index] = final;
1397
+ sections.push(planned);
1398
+ travels.push(travel);
1399
+ });
1400
+ let wanted;
1401
+ if (edge.lines !== undefined) {
1402
+ sections.forEach((section, index) => {
1403
+ const best = longest(section.stretches);
1404
+ if (!wanted || length(best) > length(sections[wanted[0]].stretches[wanted[1]])) {
1405
+ wanted = [index, section.stretches.indexOf(best)];
1406
+ }
1407
+ });
1408
+ }
1409
+ const settled = round > 0 && !moved && wanted?.[0] === carries?.[0] && wanted?.[1] === carries?.[1];
1410
+ carries = wanted;
1411
+ if (settled)
1412
+ break;
1413
+ }
1414
+ const unpassed = passes.find((pass) => !bound.has(pass));
1415
+ if (unpassed) {
1416
+ throw new SourceError(`${subject}: the line never passes ${quoteNames(unpassed.nodes)}, so "${unpassed.written}" says ` +
1417
+ 'nothing about it', edge.line);
1418
+ }
1419
+ const points = [...approach(start, a, axes[0], firstLevels[0], travels[0])];
1420
+ sections.forEach((section, index) => {
1421
+ points.push(...crossings(section));
1422
+ if (index < last)
1423
+ points.push(section.axes.make(firstLevels[index + 1], lastLevels[index]));
1424
+ });
1425
+ points.push(...approach(end, b, axes[last], lastLevels[last], -travels[last]).reverse());
1426
+ const broken = breaks(points);
1427
+ if (broken) {
1428
+ throw new SourceError(`${subject}: a line passing things both above or below and left or right has to turn, and ` +
1429
+ `no way it can turn keeps "${broken.written}" — drop that clause, or one of the others`, edge.line);
1430
+ }
1431
+ const [which, index] = carries ?? [0, 0];
1432
+ const carrier = edge.lines === undefined ? undefined : sections[which].stretches[index];
1433
+ return finish(points, carrier, axes[which]);
1434
+ }
1435
+ /** The first clause the drawn line breaks, wherever it lies alongside that clause's nodes. */
1436
+ function breaks(points) {
1437
+ for (const pass of passes) {
1438
+ const box = boundingBox(pass.nodes.map(faceOf));
1439
+ const across = sideAxis(pass);
1440
+ const run = across === 'y' ? 'x' : 'y';
1441
+ const further = pass.direction === 'below' || pass.direction === 'right';
1442
+ for (let index = 0; index + 1 < points.length; index += 1) {
1443
+ const [p, q] = [points[index], points[index + 1]];
1444
+ const alongside = Math.abs(p[run] - q[run]) < 0.5
1445
+ ? p[run] > lo(box, run) + 0.5 && p[run] < hi(box, run) - 0.5
1446
+ : Math.min(Math.max(p[run], q[run]), hi(box, run)) -
1447
+ Math.max(Math.min(p[run], q[run]), lo(box, run)) > 0.5;
1448
+ if (!alongside)
1449
+ continue;
1450
+ const kept = further
1451
+ ? Math.min(p[across], q[across]) >= hi(box, across) - 0.5
1452
+ : Math.max(p[across], q[across]) <= lo(box, across) + 0.5;
1453
+ if (!kept)
1454
+ return pass;
1455
+ }
1456
+ }
1457
+ return undefined;
1458
+ }
1459
+ /**
1460
+ * Where the line reaches along a run at one end, before it turns onto a
1461
+ * stretch. Enough to tell which nodes it passes.
1462
+ */
1463
+ function reach(anchor, face, { run }) {
1464
+ const along = run === 'x' ? anchor.tx : anchor.ty;
1465
+ return anchor.side !== undefined && along !== 0
1466
+ ? anchor[run] + along * ROUTE_STUB
1467
+ : named(anchor, face)[run];
1468
+ }
1469
+ function length(stretch) {
1470
+ return Math.abs(stretch.to - stretch.from);
1471
+ }
1472
+ function longest(stretches) {
1473
+ return stretches.reduce((best, stretch) => (length(stretch) > length(best) ? stretch : best));
1474
+ }
1475
+ /**
1476
+ * The way from `first` to `last` along a run, cut wherever a clause starts
1477
+ * or stops binding, with each piece given the band its clauses leave the
1478
+ * line, and consecutive pieces grouped into stretches that can share one
1479
+ * level. Levels are left for `settle`.
1480
+ */
1481
+ function cut(axes, passes, first, last, travel) {
1482
+ const { across, run } = axes;
1483
+ const low = Math.min(first, last);
1484
+ const high = Math.max(first, last);
1485
+ const clauses = passes.map((pass) => {
1486
+ const box = boundingBox(pass.nodes.map(faceOf));
1487
+ if (hi(box, run) <= low || lo(box, run) >= high) {
1488
+ throw new SourceError(`${subject}: the line never passes ${quoteNames(pass.nodes)}, so "${pass.written}" says nothing ` +
1489
+ 'about it', edge.line);
1490
+ }
1491
+ const further = pass.direction === 'below' || pass.direction === 'right';
1492
+ return {
1493
+ pass,
1494
+ from: lo(box, run),
1495
+ to: hi(box, run),
1496
+ bound: further ? hi(box, across) + clear : lo(box, across) - clear,
1497
+ further,
1498
+ };
1499
+ });
1500
+ // Cut the way into pieces at every place a clause starts or stops binding,
1501
+ // and give each piece the band its clauses leave the line.
1502
+ const cuts = [...new Set([low, high, ...clauses.flatMap((c) => [c.from, c.to])])]
1503
+ .filter((at) => at >= low && at <= high)
1504
+ .sort((p, q) => (p - q) * travel);
1505
+ const pieces = cuts.slice(0, -1).map((from, index) => {
1506
+ const to = cuts[index + 1];
1507
+ const middle = (from + to) / 2;
1508
+ const binding = clauses.filter((c) => c.from < middle && c.to > middle);
1509
+ const floor = binding.filter((c) => c.further).sort((p, q) => q.bound - p.bound)[0];
1510
+ const ceiling = binding.filter((c) => !c.further).sort((p, q) => p.bound - q.bound)[0];
1511
+ if (floor && ceiling && floor.bound > ceiling.bound) {
1512
+ const grouped = [floor, ceiling].some((c) => c.pass.nodes.length > 1);
1513
+ throw new SourceError(`${subject}: "${floor.pass.written}" and "${ceiling.pass.written}" cannot both hold — there ` +
1514
+ 'is a stretch where the line is alongside both, and it cannot be ' +
1515
+ `${sideWord(floor.pass)} ${quoteNames(floor.pass.nodes)} and ${sideWord(ceiling.pass)} ` +
1516
+ `${quoteNames(ceiling.pass.nodes)} at the same point. Drop one` +
1517
+ (grouped
1518
+ ? ', or name the nodes in separate clauses so the line may cross over between them'
1519
+ : ''), edge.line);
1520
+ }
1521
+ return {
1522
+ from,
1523
+ to,
1524
+ lo: floor?.bound ?? -Infinity,
1525
+ hi: ceiling?.bound ?? Infinity,
1526
+ floor,
1527
+ ceiling,
1528
+ };
1529
+ });
1530
+ // A way of no length, as the first section of a turned route can be when
1531
+ // the corner is level with its start, is one piece binding nothing.
1532
+ if (pieces.length === 0) {
1533
+ pieces.push({ from: low, to: high, lo: -Infinity, hi: Infinity, floor: undefined, ceiling: undefined });
1534
+ }
1535
+ // Consecutive pieces share one level for as long as their bands overlap.
1536
+ const stretches = [];
1537
+ let current = { lo: -Infinity, hi: Infinity, first: 0, last: 0, from: 0, to: 0, level: 0 };
1538
+ pieces.forEach((piece, index) => {
1539
+ const lower = Math.max(current.lo, piece.lo);
1540
+ const upper = Math.min(current.hi, piece.hi);
1541
+ if (lower <= upper) {
1542
+ Object.assign(current, { lo: lower, hi: upper, last: index });
1543
+ }
1544
+ else {
1545
+ stretches.push(current);
1546
+ current = { lo: piece.lo, hi: piece.hi, first: index, last: index, from: 0, to: 0, level: 0 };
1547
+ }
1548
+ });
1549
+ stretches.push(current);
1550
+ for (const stretch of stretches) {
1551
+ stretch.from = pieces[stretch.first].from;
1552
+ stretch.to = pieces[stretch.last].to;
1553
+ }
1554
+ return { axes, pieces, stretches };
1555
+ }
1556
+ /**
1557
+ * Each stretch sits as near `target` as its band allows, and a node lying
1558
+ * on it pushes it on the way its clause already points. `carrier` is the
1559
+ * stretch held clear for the text, if this section has it.
1560
+ */
1561
+ function settle({ axes: { across, run }, stretches }, target, carrier, obstacles = inWay, hug = false, near = new Set()) {
1562
+ const textClear = Math.max(clear, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
1563
+ for (const stretch of stretches) {
1564
+ const room = stretch === carrier && edge.lines !== undefined ? textClear : clear;
1565
+ const extra = room - clear;
1566
+ const lower = stretch.lo + extra;
1567
+ const upper = stretch.hi - extra;
1568
+ // A stretch hugging its clauses sits as close as its one bound allows.
1569
+ const wanted = !hug
1570
+ ? target
1571
+ : stretch.hi === Infinity && stretch.lo !== -Infinity
1572
+ ? lower
1573
+ : stretch.lo === -Infinity && stretch.hi !== Infinity
1574
+ ? upper
1575
+ : target;
1576
+ stretch.level = lower <= upper
1577
+ ? Math.min(Math.max(wanted, lower), upper)
1578
+ : (stretch.lo + stretch.hi) / 2;
1579
+ const push = stretch.hi === Infinity ? 1 : stretch.lo === -Infinity ? -1 : 0;
1580
+ if (push === 0)
1581
+ continue;
1582
+ const from = Math.min(stretch.from, stretch.to);
1583
+ const to = Math.max(stretch.from, stretch.to);
1584
+ for (let moved = true; moved;) {
1585
+ moved = false;
1586
+ for (const node of obstacles) {
1587
+ const face = faceOf(node);
1588
+ if (lo(face, run) >= to || hi(face, run) <= from)
1589
+ continue;
1590
+ // A node the edge names is one it is meant to go close by, so it
1591
+ // keeps the line only as far off as a crossing does.
1592
+ const off = near.has(node) && room === clear ? ATTACH_MARGIN : room;
1593
+ if (lo(face, across) >= stretch.level + off || hi(face, across) <= stretch.level - off) {
1594
+ continue;
1595
+ }
1596
+ stretch.level = push > 0 ? hi(face, across) + off : lo(face, across) - off;
1597
+ moved = true;
1598
+ }
1599
+ }
1600
+ }
1601
+ }
1602
+ /**
1603
+ * Between two stretches the line crosses over, in whatever run of pieces at
1604
+ * the end of the first leaves room for both levels.
1605
+ */
1606
+ function crossings({ axes: { make }, pieces, stretches }) {
1607
+ const points = [];
1608
+ for (let index = 0; index + 1 < stretches.length; index += 1) {
1609
+ const here = stretches[index];
1610
+ const next = stretches[index + 1];
1611
+ let open = here.last + 1;
1612
+ while (open > here.first &&
1613
+ pieces[open - 1].lo <= next.level &&
1614
+ pieces[open - 1].hi >= next.level) {
1615
+ open -= 1;
1616
+ }
1617
+ const from = open <= here.last ? pieces[open].from : pieces[here.last].to;
1618
+ const to = pieces[here.last].to;
1619
+ if (Math.abs(to - from) < CROSSING_ROOM) {
1620
+ const behind = [...pieces.slice(here.first, here.last + 1)]
1621
+ .reverse()
1622
+ .map((piece) => piece.floor ?? piece.ceiling)
1623
+ .find((clause) => clause !== undefined);
1624
+ const entering = pieces[next.first];
1625
+ const ahead = (next.level > here.level ? entering.floor : entering.ceiling) ??
1626
+ entering.floor ?? entering.ceiling;
1627
+ throw new SourceError(`${subject}: to pass "${behind.pass.written}" and "${ahead.pass.written}" the line has to ` +
1628
+ `cross over between ${quoteNames(behind.pass.nodes)} and ${quoteNames(ahead.pass.nodes)}, and there ` +
1629
+ 'is no room between them — give the placement between them a gap, or drop one of the two', edge.line);
1630
+ }
1631
+ const at = (from + to) / 2;
1632
+ points.push(make(at, here.level), make(at, next.level));
1633
+ }
1634
+ return points;
1635
+ }
1636
+ /**
1637
+ * The route drawn through `points`, with its text at the middle of the
1638
+ * piece that lies on the stretch held clear for it.
1639
+ */
1640
+ function finish(points, carrier, { across, run }) {
1641
+ const drawn = tidyRoute(points);
1642
+ let mid = centerOf(boundingBox([a, b]));
1643
+ let best = -1;
1644
+ for (let index = 0; index + 1 < drawn.length; index += 1) {
1645
+ const [p, q] = [drawn[index], drawn[index + 1]];
1646
+ if (!carrier)
1647
+ break;
1648
+ if (Math.abs(p[across] - carrier.level) > 0.5 || Math.abs(q[across] - carrier.level) > 0.5) {
1649
+ continue;
1650
+ }
1651
+ const length = Math.abs(q[run] - p[run]);
1652
+ if (length > best) {
1653
+ best = length;
1654
+ mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
1655
+ }
1656
+ }
1657
+ return { points: drawn, mid };
1658
+ }
1659
+ /**
1660
+ * The points from one end of the line to the level of its nearest stretch.
1661
+ * `toward` is the way the line heads along its run from this end.
1662
+ */
1663
+ function approach(given, face, axes, level, toward) {
1664
+ const { across, run, make } = axes;
1665
+ const anchor = given.side !== undefined ? given : leaving(face, axes, level, toward);
1666
+ const along = run === 'x' ? anchor.tx : anchor.ty;
1667
+ const out = across === 'x' ? anchor.tx : anchor.ty;
1668
+ if (along !== 0) {
1669
+ // Out along the run, one way or the other, then across to the level.
1670
+ const turn = make(anchor[run] + along * ROUTE_STUB, anchor[across]);
1671
+ return [anchor, turn, make(turn[run], level)];
1672
+ }
1673
+ if ((level - anchor[across]) * out >= 0)
1674
+ return [anchor, make(anchor[run], level)];
1675
+ // The side faces away from the level, so the line steps round the back of
1676
+ // its own node — the end the run is heading away from — to get there.
1677
+ const stub = make(anchor[run], anchor[across] + out * ROUTE_STUB);
1678
+ const back = toward > 0 ? lo(face, run) - ROUTE_STUB : hi(face, run) + ROUTE_STUB;
1679
+ return [anchor, stub, make(back, stub[across]), make(back, level)];
1680
+ }
1681
+ /**
1682
+ * An end naming no side leaves from the one facing the level it is going
1683
+ * to, or, when the level is alongside the node, the one facing its way.
1684
+ */
1685
+ function leaving(face, { across, run }, level, toward) {
1686
+ const center = centerOf(face);
1687
+ const side = level > hi(face, across)
1688
+ ? across === 'y' ? 'bottom' : 'right'
1689
+ : level < lo(face, across)
1690
+ ? across === 'y' ? 'top' : 'left'
1691
+ : run === 'x'
1692
+ ? toward > 0 ? 'right' : 'left'
1693
+ : toward > 0 ? 'bottom' : 'top';
1694
+ return anchorOn(face, side, side === 'top' || side === 'bottom' ? center.x : center.y);
1695
+ }
1696
+ }
1697
+ /** Which axis a clause's side sits on: above and below are a matter of y. */
1698
+ function sideAxis(pass) {
1699
+ return pass.direction === 'above' || pass.direction === 'below' ? 'y' : 'x';
1700
+ }
1701
+ /** "below", "left of" — the side as the author would say it. */
1702
+ function sideWord(pass) {
1703
+ return pass.direction === 'above' || pass.direction === 'below'
1704
+ ? pass.direction
1705
+ : `${pass.direction} of`;
1706
+ }
1707
+ /** The box that just bounds several. */
1708
+ function boundingBox(boxes) {
1709
+ const x = Math.min(...boxes.map((box) => box.x));
1710
+ const y = Math.min(...boxes.map((box) => box.y));
1711
+ return {
1712
+ x,
1713
+ y,
1714
+ width: Math.max(...boxes.map((box) => box.x + box.width)) - x,
1715
+ height: Math.max(...boxes.map((box) => box.y + box.height)) - y,
1716
+ };
1717
+ }
1718
+ /** `"a"`, `"a" and "b"` — for error messages. */
1719
+ function quoteNames(nodes) {
1720
+ const quoted = nodes.map((node) => `"${node.name}"`);
1721
+ return quoted.length <= 1
1722
+ ? (quoted[0] ?? '')
1723
+ : `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`;
1724
+ }
1725
+ /** Drop repeated points and ones partway along a straight piece, which are not corners. */
1726
+ function tidyRoute(points) {
1727
+ const kept = [];
1728
+ for (const point of points) {
1729
+ const previous = kept[kept.length - 1];
1730
+ if (previous && Math.hypot(point.x - previous.x, point.y - previous.y) < 0.5)
1731
+ continue;
1732
+ const before = kept[kept.length - 2];
1733
+ if (before && previous &&
1734
+ Math.abs((previous.x - before.x) * (point.y - previous.y) - (previous.y - before.y) * (point.x - previous.x)) < 1e-6 &&
1735
+ (previous.x - before.x) * (point.x - previous.x) + (previous.y - before.y) * (point.y - previous.y) >= 0) {
1736
+ kept[kept.length - 1] = point;
1737
+ continue;
1738
+ }
1739
+ kept.push(point);
1740
+ }
1741
+ return kept;
1742
+ }
1743
+ /**
1744
+ * A line through `points` with every corner rounded. A corner takes at most
1745
+ * half of each piece it shares with a neighboring corner, and the whole of a
1746
+ * piece at either end short of the arrowhead, so two corners never overlap and
1747
+ * the head always lands on a straight piece.
1748
+ */
1749
+ function roundedPath(points) {
1750
+ const parts = [`M ${round(points[0].x)} ${round(points[0].y)}`];
1751
+ const kappa = 0.5523; // a cubic's handle, as a share of the radius, for a quarter circle
1752
+ for (let index = 1; index + 1 < points.length; index += 1) {
1753
+ const [before, corner, after] = [points[index - 1], points[index], points[index + 1]];
1754
+ const inward = Math.hypot(corner.x - before.x, corner.y - before.y);
1755
+ const outward = Math.hypot(after.x - corner.x, after.y - corner.y);
1756
+ const radius = Math.max(0, Math.min(ROUTE_RADIUS, index === 1 ? inward - ARROW_LENGTH : inward / 2, index + 2 === points.length ? outward - ARROW_LENGTH : outward / 2));
1757
+ const din = { x: (corner.x - before.x) / inward, y: (corner.y - before.y) / inward };
1758
+ const dout = { x: (after.x - corner.x) / outward, y: (after.y - corner.y) / outward };
1759
+ const enter = { x: corner.x - din.x * radius, y: corner.y - din.y * radius };
1760
+ const leave = { x: corner.x + dout.x * radius, y: corner.y + dout.y * radius };
1761
+ parts.push(`L ${round(enter.x)} ${round(enter.y)}`, `C ${round(enter.x + din.x * radius * kappa)} ${round(enter.y + din.y * radius * kappa)}, ` +
1762
+ `${round(leave.x - dout.x * radius * kappa)} ${round(leave.y - dout.y * radius * kappa)}, ` +
1763
+ `${round(leave.x)} ${round(leave.y)}`);
1764
+ }
1765
+ const last = points[points.length - 1];
1766
+ parts.push(`L ${round(last.x)} ${round(last.y)}`);
1767
+ return parts.join(' ');
1768
+ }
1769
+ function lo(box, axis) {
1770
+ return axis === 'x' ? box.x : box.y;
1771
+ }
1772
+ function hi(box, axis) {
1773
+ return axis === 'x' ? box.x + box.width : box.y + box.height;
1774
+ }
1775
+ /** Whether `inner` is `outer` or sits somewhere inside it. */
1776
+ function contains(outer, inner) {
1777
+ for (let node = inner; node; node = node.parent) {
1778
+ if (node === outer)
1779
+ return true;
1780
+ }
1781
+ return false;
1782
+ }
1021
1783
  /**
1022
1784
  * An end whose side the author did not name aims at the far box's center, which
1023
1785
  * is the wrong thing to aim at once the line has been told to go somewhere else
@@ -1070,10 +1832,11 @@ function corridorPath(start, end, plan) {
1070
1832
  // Along the run the line travels one way, so that is its tangent at both ends
1071
1833
  // of the straight stretch — it enters the gap already going where the gap goes.
1072
1834
  const rt = plan.axis === 'y' ? { tx: forward, ty: 0 } : { tx: 0, ty: forward };
1073
- const r1 = corridorReach(start, p1, plan.axis);
1835
+ const reach = plan.loop ? loopReach : corridorReach;
1836
+ const r1 = reach(start, p1, plan.axis);
1074
1837
  const c1 = { x: start.x + start.tx * r1, y: start.y + start.ty * r1 };
1075
1838
  const c2 = { x: p1.x - rt.tx * r1, y: p1.y - rt.ty * r1 };
1076
- const r2 = corridorReach(p2, end, plan.axis);
1839
+ const r2 = reach(p2, end, plan.axis);
1077
1840
  const c3 = { x: p2.x + rt.tx * r2, y: p2.y + rt.ty * r2 };
1078
1841
  const c4 = { x: end.x + end.tx * r2, y: end.y + end.ty * r2 };
1079
1842
  const d = [
@@ -1096,6 +1859,15 @@ function corridorReach(from, to, axis) {
1096
1859
  const distance = Math.hypot(to.x - from.x, to.y - from.y);
1097
1860
  return Math.min(140, Math.max(8, Math.min(distance * 0.4, run / 2)));
1098
1861
  }
1862
+ /**
1863
+ * How far the handles reach on the turn at either end of a loop. The turn
1864
+ * leaves heading one way and joins the run heading the other, over the depth
1865
+ * between the side and the run, so it is a half circle on that depth — and a
1866
+ * cubic comes closest to a half circle with handles two thirds of its diameter.
1867
+ */
1868
+ function loopReach(from, to, axis) {
1869
+ return (Math.abs(axis === 'y' ? to.y - from.y : to.x - from.x) * 2) / 3;
1870
+ }
1099
1871
  function sideAttr(edge, key) {
1100
1872
  const value = edge.attrs[key];
1101
1873
  if (value === undefined)