reladraw 0.5.0 → 0.7.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,5 +1,5 @@
1
- import { ALL_ATTR_KEYS, ATTR_KEYS, PART_SIDES, COLOR_KEYS, COLOR_PARTS, CONTENT_ALIGNMENTS, CONTENT_WIDTHS, describePlacement, nameTarget, } from './ast.js';
2
- import { ARROW_LENGTH, CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, TEXT_CLEARANCE, PAD, SEPARATION_GAP, fontSizeFor, leafTop, textExtent, textStyleFor, widestLine, } from './constants.js';
1
+ import { ALL_ATTR_KEYS, ATTR_KEYS, PART_SIDES, COLOR_KEYS, COLOR_PARTS, CONTENT_ALIGNMENTS, CONTENT_WIDTHS, LINE_VALUES, describePlacement, nameTarget, } from './ast.js';
2
+ import { 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';
5
5
  import { bodyFor } from './icons.js';
@@ -457,6 +457,25 @@ function buildEdges(statements, byName, styles, defaults) {
457
457
  }),
458
458
  ...(stmt.between.axis !== undefined ? { axis: stmt.between.axis } : {}),
459
459
  };
460
+ const passes = stmt.passes?.map((placement) => {
461
+ const written = describePlacement(placement);
462
+ return {
463
+ direction: placement.direction,
464
+ written,
465
+ nodes: placement.targets.map(({ name, part }) => {
466
+ if (part !== undefined) {
467
+ // The line passes a box, and a side or a point has no side of its
468
+ // own to be passed on. Refused by name rather than dropped.
469
+ throw new SourceError(`edge passes "${written}", and a line passes a whole box — drop "${part}"`, stmt.line);
470
+ }
471
+ const node = byName.get(name);
472
+ if (!node) {
473
+ throw new SourceError(`edge passes "${written}", and "${name}" does not exist`, stmt.line);
474
+ }
475
+ return node;
476
+ }),
477
+ };
478
+ });
460
479
  const appearance = { ...defaults.get('edge'), ...appearanceOf(stmt.attrs, styles, stmt.line) };
461
480
  const what = `${stmt.from} -> ${stmt.to}`;
462
481
  checkAttrs('edge', what, stmt.attrs, stmt.line);
@@ -479,6 +498,8 @@ function buildEdges(statements, byName, styles, defaults) {
479
498
  ? { text: stmt.text, lines: linesFor(stmt.text, textAttrs, `edge ${what}`, stmt.line) }
480
499
  : {}),
481
500
  ...(between ? { between } : {}),
501
+ ...(passes ? { passes } : {}),
502
+ look: lineLook(stmt.attrs, appearance, what, stmt.line),
482
503
  attrs: stmt.attrs,
483
504
  appearance,
484
505
  line: stmt.line,
@@ -486,6 +507,49 @@ function buildEdges(statements, byName, styles, defaults) {
486
507
  }
487
508
  return edges;
488
509
  }
510
+ /**
511
+ * The line's bracket, checked, with a default for everything unsaid. A value is
512
+ * checked wherever it came from — a style's `path: squar` is as much a typo as
513
+ * an edge's — but `corners: rounded` is refused only when the edge itself says
514
+ * it on a curved line, which has no corners. From a style or a default it
515
+ * applies to the edges that have corners, the way a style's `fill:` lands on
516
+ * the nodes that have an inside.
517
+ */
518
+ function lineLook(written, appearance, what, line) {
519
+ const word = (property) => {
520
+ const value = appearance[`line.${property}`];
521
+ if (value === undefined)
522
+ return LINE_VALUES[property][0];
523
+ if (!LINE_VALUES[property].includes(value)) {
524
+ throw new SourceError(`edge ${what}: "${property}: ${value}" is not a ${property} — use ${(property === 'thickness' ? ['thin', 'normal', 'thick'] : LINE_VALUES[property]).join(', ')}` +
525
+ (property === 'thickness' ? ', or a number of pixels' : ''), line);
526
+ }
527
+ return value;
528
+ };
529
+ const thickness = (() => {
530
+ const value = appearance['line.thickness'];
531
+ if (value !== undefined && /^[0-9.]/.test(value)) {
532
+ if (!/^\d+(\.\d+)?$/.test(value) || Number(value) <= 0) {
533
+ throw new SourceError(`edge ${what}: "thickness: ${value}" — a thickness is thin, normal, thick, ` +
534
+ 'or a plain number of pixels greater than zero, such as `thickness: 3`', line);
535
+ }
536
+ return Number(value);
537
+ }
538
+ return THICKNESS[word('thickness')];
539
+ })();
540
+ const look = {
541
+ path: word('path'),
542
+ corners: word('corners'),
543
+ crossing: word('crossing'),
544
+ pattern: word('pattern'),
545
+ thickness,
546
+ };
547
+ if (look.path === 'curved' && written['line.corners'] === 'rounded') {
548
+ throw new SourceError(`edge ${what}: a curved line has no corners to round — \`corners: rounded\` takes a ` +
549
+ '`path: square` or `path: straight` line', line);
550
+ }
551
+ return look;
552
+ }
489
553
  /**
490
554
  * Give a node a width and height, sizing its children first. Also records each
491
555
  * child's offset within this node, which pass three turns into absolute
@@ -1268,6 +1332,30 @@ const AXIS_WORD = { x: 'horizontally', y: 'vertically' };
1268
1332
  function memberOn(target, axis) {
1269
1333
  return target.byAxis ? target.byAxis[axis] : target.index;
1270
1334
  }
1335
+ /**
1336
+ * The ways named sides can face away from the other end. Opposite sides face
1337
+ * away together or not at all (`left right` with `from` first); sides at right
1338
+ * angles each may, and either is enough (`left top` with `from` first, where
1339
+ * the edge turns back over the top, or with `to` below, where it goes round).
1340
+ */
1341
+ function facingAway(fromSide, toSide) {
1342
+ // The axis a side is on, and whether it faces away when its own end is first.
1343
+ const SIDES = {
1344
+ left: { axis: 'x', first: true },
1345
+ right: { axis: 'x', first: false },
1346
+ top: { axis: 'y', first: true },
1347
+ bottom: { axis: 'y', first: false },
1348
+ };
1349
+ const f = SIDES[String(fromSide)];
1350
+ const t = SIDES[String(toSide)];
1351
+ if (!f || !t)
1352
+ return [];
1353
+ const byFrom = { axis: f.axis, fromFirst: f.first };
1354
+ const byTo = { axis: t.axis, fromFirst: !t.first };
1355
+ if (f.axis !== t.axis)
1356
+ return [byFrom, byTo];
1357
+ return byFrom.fromFirst === byTo.fromFirst ? [byFrom] : [];
1358
+ }
1271
1359
  /**
1272
1360
  * Where a node sits within the group being solved: which member holds it, and
1273
1361
  * where inside that member. An edge may name anything at any depth, so its ends
@@ -1303,7 +1391,9 @@ function corridorsIn(edges, locate, measurer, fontSize) {
1303
1391
  // edge told to pass between two named things carries its text in *that*
1304
1392
  // corridor rather than in the gap between its own ends, so widening this one
1305
1393
  // would make room where the text never goes.
1306
- if (edge.text === undefined || edge.between)
1394
+ // An edge that says which side of something it passes is routed round it,
1395
+ // and its text rides on that route for the same reason.
1396
+ if (edge.text === undefined || edge.between || edge.passes)
1307
1397
  continue;
1308
1398
  const from = locate(edge.from);
1309
1399
  const to = locate(edge.to);
@@ -1312,11 +1402,18 @@ function corridorsIn(edges, locate, measurer, fontSize) {
1312
1402
  // The clearance is doubled because the text is drawn at the *midpoint* of
1313
1403
  // the line, so the room it needs is symmetric about that point whatever sits
1314
1404
  // at either end. The arrowhead is charged on both sides for the same reason:
1315
- // it covers `ARROW_LENGTH` of the line it arrives on, and reserving that at
1316
- // one end only would move the midpoint rather than lengthen the run.
1405
+ // it covers the head's length of the line it arrives on, and reserving that
1406
+ // at one end only would move the midpoint rather than lengthen the run. A
1407
+ // thick line's head is longer, and takes the room it needs.
1317
1408
  const extent = (axis) => textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line) +
1318
- (TEXT_CLEARANCE + ARROW_LENGTH) * 2;
1319
- corridors.push({ edge, from, to, need: { x: extent('x'), y: extent('y') } });
1409
+ (TEXT_CLEARANCE + arrowLength(edge.look.thickness)) * 2;
1410
+ corridors.push({
1411
+ edge,
1412
+ from,
1413
+ to,
1414
+ need: { x: extent('x'), y: extent('y') },
1415
+ away: facingAway(edge.attrs['from'], edge.attrs['to']),
1416
+ });
1320
1417
  }
1321
1418
  return corridors;
1322
1419
  }
@@ -1658,7 +1755,7 @@ function alignedAt(side, span, own) {
1658
1755
  */
1659
1756
  function room(corridors, constraints, solved, solveAll, made) {
1660
1757
  let added = false;
1661
- for (const [index, { from, to, need }] of corridors.entries()) {
1758
+ for (const [index, { from, to, need, away }] of corridors.entries()) {
1662
1759
  const clear = (axis) => {
1663
1760
  const at = (end) => solved[axis][end.index] + end.offset[axis];
1664
1761
  const size = (end) => (axis === 'x' ? end.width : end.height);
@@ -1672,6 +1769,11 @@ function room(corridors, constraints, solved, solveAll, made) {
1672
1769
  if (open.length !== 1)
1673
1770
  continue;
1674
1771
  const axis = open[0];
1772
+ // Ends facing away along the only open axis mean the boxes share a row, and
1773
+ // the renderer takes the line over the top with its text — see `planLoops`.
1774
+ // Widening the gap would make room where the text never goes.
1775
+ if (away.some((way) => way.axis === axis && (clear(axis).before === from) === way.fromFirst))
1776
+ continue;
1675
1777
  // Given already, and a minimum stays met: asking again would widen
1676
1778
  // nothing and only keep the caller looking.
1677
1779
  if (made.has(`${index}:${axis}`))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {