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/resolve.js CHANGED
@@ -23,11 +23,12 @@ export function resolve(doc, options = {}) {
23
23
  const margin = options.margin ?? DEFAULT_MARGIN;
24
24
  const styles = collectStyles(doc.statements);
25
25
  checkStyleKeys(doc.statements);
26
- const { nodes, byName, roots } = buildTree(doc.statements, styles);
26
+ const defaults = collectDefaults(doc.statements, styles);
27
+ const { nodes, byName, roots } = buildTree(doc.statements, styles, defaults);
27
28
  // Edges are resolved to nodes before anything is sized, because a labeled
28
29
  // edge claims room in the gap it crosses and so has to be in hand while the
29
30
  // gaps are being worked out. Nothing here reads geometry.
30
- const edges = buildEdges(doc.statements, byName, styles);
31
+ const edges = buildEdges(doc.statements, byName, styles, defaults);
31
32
  const local = new Map();
32
33
  for (const root of roots)
33
34
  sizeNode(root, edges, measurer, fontSize, local);
@@ -58,6 +59,40 @@ function collectStyles(statements) {
58
59
  }
59
60
  return styles;
60
61
  }
62
+ /**
63
+ * Each kind's default, with any style it names already folded in beneath its
64
+ * own words, so a default arrives at a node as one flat bundle. The same kind
65
+ * written twice is refused, as a second `diagram` is: nothing says which was
66
+ * meant.
67
+ */
68
+ function collectDefaults(statements, styles) {
69
+ const defaults = new Map();
70
+ const seen = new Map();
71
+ for (const stmt of statements) {
72
+ if (stmt.kind !== 'default')
73
+ continue;
74
+ const earlier = seen.get(stmt.target);
75
+ if (earlier !== undefined) {
76
+ throw new SourceError(`default ${stmt.target} is written twice, here and on line ${earlier} — keep one`, stmt.line);
77
+ }
78
+ seen.set(stmt.target, stmt.line);
79
+ const { style: _named, ...flat } = appearanceOf(stmt.attrs, styles, stmt.line);
80
+ defaults.set(stmt.target, flat);
81
+ }
82
+ return defaults;
83
+ }
84
+ /**
85
+ * One bundle laid over a weaker one, key by key — except the body, which is
86
+ * one thing said by either of two words. A default's `shape: cylinder` under
87
+ * a style's `icon: disk` is not a node with two bodies; the style said what
88
+ * this node is drawn as, and that settles it.
89
+ */
90
+ function over(base, top) {
91
+ if (top['shape'] === undefined && top['icon'] === undefined)
92
+ return { ...base, ...top };
93
+ const { shape: _shape, icon: _icon, ...rest } = base;
94
+ return { ...rest, ...top };
95
+ }
61
96
  /** A file holds one diagram, so a second `diagram` statement is a mistake. */
62
97
  function collectDiagram(statements) {
63
98
  let found;
@@ -70,12 +105,27 @@ function collectDiagram(statements) {
70
105
  }
71
106
  return found ?? {};
72
107
  }
73
- function buildTree(statements, styles) {
108
+ function buildTree(statements, styles, defaults) {
74
109
  const nodes = [];
75
110
  const byName = new Map();
76
111
  const roots = [];
77
112
  /** Each badge child's name, and the node whose `badge:` it was written out from. */
78
113
  const badges = new Map();
114
+ // Which nodes will hold children has to be known before any node is built,
115
+ // because it decides which default a node wears, and its children are
116
+ // declared after it. A child is a dotted name, or a badge the node's own
117
+ // line or styles give it; a default cannot give a leaf a badge, so what
118
+ // the defaults say cannot change the answer.
119
+ const parents = new Set();
120
+ for (const stmt of statements) {
121
+ if (stmt.kind !== 'node')
122
+ continue;
123
+ const cut = stmt.name.lastIndexOf('.');
124
+ if (cut !== -1)
125
+ parents.add(stmt.name.slice(0, cut));
126
+ if (appearanceOf(stmt.attrs, styles, stmt.line)['badge'] !== undefined)
127
+ parents.add(stmt.name);
128
+ }
79
129
  for (const stmt of statements) {
80
130
  if (stmt.kind !== 'node')
81
131
  continue;
@@ -87,7 +137,9 @@ function buildTree(statements, styles) {
87
137
  }
88
138
  throw new SourceError(`"${stmt.name}" is declared twice`, stmt.line);
89
139
  }
90
- const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
140
+ // Weakest first: every node's default, then the leaf's or the container's,
141
+ // then the node's own styles and words over both.
142
+ const appearance = over(over(defaults.get('node') ?? {}, defaults.get(parents.has(stmt.name) ? 'container' : 'leaf') ?? {}), appearanceOf(stmt.attrs, styles, stmt.line));
91
143
  const body = bodyFor(stmt.attrs, appearance, stmt.line);
92
144
  const kind = KIND_OF_BODY[body.kind];
93
145
  // A node with no text of its own is labelled with its name, because the
@@ -275,6 +327,11 @@ function checkAttrs(kind, name, attrs, line) {
275
327
  const parts = COLOR_PARTS[kind].map((part) => `\`${part}\``).join(', ');
276
328
  throw new SourceError(`"${name}" is ${article(KIND_WORD[kind])} and has ${wrote}. ${capital(article(KIND_WORD[kind]))} is ${KIND_PARTS[kind]}, so it has no ${key} — it takes ${parts}`, line);
277
329
  }
330
+ // A word only the whole drawing takes, such as a theme.
331
+ if (belongTo(key).length === 0) {
332
+ throw new SourceError(`"${name}" is ${article(KIND_WORD[kind])} and has ${wrote}. \`${key}:\` is said about the ` +
333
+ `whole drawing — write \`diagram ${key}: ${shown}\``, line);
334
+ }
278
335
  // Anything else names no part, so what the kind is made of explains
279
336
  // nothing. What does explain it is where the word *does* belong, which is
280
337
  // also the more useful thing to be told: the author has usually written a
@@ -373,7 +430,7 @@ function appearanceOf(attrs, styles, line) {
373
430
  }
374
431
  return { ...merged, ...attrs };
375
432
  }
376
- function buildEdges(statements, byName, styles) {
433
+ function buildEdges(statements, byName, styles, defaults) {
377
434
  const edges = [];
378
435
  for (const stmt of statements) {
379
436
  if (stmt.kind !== 'edge')
@@ -400,7 +457,26 @@ function buildEdges(statements, byName, styles) {
400
457
  }),
401
458
  ...(stmt.between.axis !== undefined ? { axis: stmt.between.axis } : {}),
402
459
  };
403
- const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
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
+ });
479
+ const appearance = { ...defaults.get('edge'), ...appearanceOf(stmt.attrs, styles, stmt.line) };
404
480
  const what = `${stmt.from} -> ${stmt.to}`;
405
481
  checkAttrs('edge', what, stmt.attrs, stmt.line);
406
482
  checkStyleUse('edge', what, stmt.attrs, styles, stmt.line);
@@ -422,6 +498,7 @@ function buildEdges(statements, byName, styles) {
422
498
  ? { text: stmt.text, lines: linesFor(stmt.text, textAttrs, `edge ${what}`, stmt.line) }
423
499
  : {}),
424
500
  ...(between ? { between } : {}),
501
+ ...(passes ? { passes } : {}),
425
502
  attrs: stmt.attrs,
426
503
  appearance,
427
504
  line: stmt.line,
@@ -1211,6 +1288,30 @@ const AXIS_WORD = { x: 'horizontally', y: 'vertically' };
1211
1288
  function memberOn(target, axis) {
1212
1289
  return target.byAxis ? target.byAxis[axis] : target.index;
1213
1290
  }
1291
+ /**
1292
+ * The ways named sides can face away from the other end. Opposite sides face
1293
+ * away together or not at all (`left right` with `from` first); sides at right
1294
+ * angles each may, and either is enough (`left top` with `from` first, where
1295
+ * the edge turns back over the top, or with `to` below, where it goes round).
1296
+ */
1297
+ function facingAway(fromSide, toSide) {
1298
+ // The axis a side is on, and whether it faces away when its own end is first.
1299
+ const SIDES = {
1300
+ left: { axis: 'x', first: true },
1301
+ right: { axis: 'x', first: false },
1302
+ top: { axis: 'y', first: true },
1303
+ bottom: { axis: 'y', first: false },
1304
+ };
1305
+ const f = SIDES[String(fromSide)];
1306
+ const t = SIDES[String(toSide)];
1307
+ if (!f || !t)
1308
+ return [];
1309
+ const byFrom = { axis: f.axis, fromFirst: f.first };
1310
+ const byTo = { axis: t.axis, fromFirst: !t.first };
1311
+ if (f.axis !== t.axis)
1312
+ return [byFrom, byTo];
1313
+ return byFrom.fromFirst === byTo.fromFirst ? [byFrom] : [];
1314
+ }
1214
1315
  /**
1215
1316
  * Where a node sits within the group being solved: which member holds it, and
1216
1317
  * where inside that member. An edge may name anything at any depth, so its ends
@@ -1246,7 +1347,9 @@ function corridorsIn(edges, locate, measurer, fontSize) {
1246
1347
  // edge told to pass between two named things carries its text in *that*
1247
1348
  // corridor rather than in the gap between its own ends, so widening this one
1248
1349
  // would make room where the text never goes.
1249
- if (edge.text === undefined || edge.between)
1350
+ // An edge that says which side of something it passes is routed round it,
1351
+ // and its text rides on that route for the same reason.
1352
+ if (edge.text === undefined || edge.between || edge.passes)
1250
1353
  continue;
1251
1354
  const from = locate(edge.from);
1252
1355
  const to = locate(edge.to);
@@ -1259,7 +1362,13 @@ function corridorsIn(edges, locate, measurer, fontSize) {
1259
1362
  // one end only would move the midpoint rather than lengthen the run.
1260
1363
  const extent = (axis) => textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line) +
1261
1364
  (TEXT_CLEARANCE + ARROW_LENGTH) * 2;
1262
- corridors.push({ edge, from, to, need: { x: extent('x'), y: extent('y') } });
1365
+ corridors.push({
1366
+ edge,
1367
+ from,
1368
+ to,
1369
+ need: { x: extent('x'), y: extent('y') },
1370
+ away: facingAway(edge.attrs['from'], edge.attrs['to']),
1371
+ });
1263
1372
  }
1264
1373
  return corridors;
1265
1374
  }
@@ -1601,7 +1710,7 @@ function alignedAt(side, span, own) {
1601
1710
  */
1602
1711
  function room(corridors, constraints, solved, solveAll, made) {
1603
1712
  let added = false;
1604
- for (const [index, { from, to, need }] of corridors.entries()) {
1713
+ for (const [index, { from, to, need, away }] of corridors.entries()) {
1605
1714
  const clear = (axis) => {
1606
1715
  const at = (end) => solved[axis][end.index] + end.offset[axis];
1607
1716
  const size = (end) => (axis === 'x' ? end.width : end.height);
@@ -1615,6 +1724,11 @@ function room(corridors, constraints, solved, solveAll, made) {
1615
1724
  if (open.length !== 1)
1616
1725
  continue;
1617
1726
  const axis = open[0];
1727
+ // Ends facing away along the only open axis mean the boxes share a row, and
1728
+ // the renderer takes the line over the top with its text — see `planLoops`.
1729
+ // Widening the gap would make room where the text never goes.
1730
+ if (away.some((way) => way.axis === axis && (clear(axis).before === from) === way.fromFirst))
1731
+ continue;
1618
1732
  // Given already, and a minimum stays met: asking again would widen
1619
1733
  // nothing and only keep the caller looking.
1620
1734
  if (made.has(`${index}:${axis}`))
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The named themes a file chooses with `diagram theme: <name>`, and the one it
3
+ * gets when it says nothing.
4
+ *
5
+ * A theme supplies every color the file does not state. It never supplies a
6
+ * size or a distance, so switching themes cannot move anything — geometry may
7
+ * not depend on appearance.
8
+ *
9
+ * Most of the palettes are borrowed from editor color schemes, all of them MIT
10
+ * licensed and credited in NOTICE. Those palettes were made for code, where a
11
+ * color marks a keyword or a string; a diagram needs a page, two box fills, a
12
+ * border, text and a line. So each is the scheme read as a diagram rather than
13
+ * a transfer of it: the page is the scheme's background, a leaf is its raised
14
+ * surface, a container sits between the two, and the lines take one of its
15
+ * accents.
16
+ */
17
+ export interface Theme {
18
+ background: string;
19
+ boxFill: string;
20
+ boxStroke: string;
21
+ containerFill: string;
22
+ /** A container is a region rather than a thing, so its outline is quieter. */
23
+ containerStroke: string;
24
+ text: string;
25
+ mutedText: string;
26
+ edge: string;
27
+ /** An icon's drawn line. */
28
+ iconInk: string;
29
+ /** The body an icon's lines enclose. */
30
+ iconShade: string;
31
+ }
32
+ /**
33
+ * Sampled out of `examples/reference/arch.png` rather than invented,
34
+ * so the benchmark render and the drawing it is measured against differ by
35
+ * geometry and typography alone. A container is a shade off the page and barely
36
+ * outlined; a leaf is the navy that carries the diagram's weight.
37
+ */
38
+ export declare const DARK_THEME: Theme;
39
+ /**
40
+ * Every theme a file may name, in the order they are offered. Pairs sit
41
+ * together, dark first, and the two that have no light half follow them.
42
+ */
43
+ export declare const THEMES: Readonly<Record<string, Theme>>;
44
+ /** The theme a file gets when it names none. */
45
+ export declare const DEFAULT_THEME = "dark";
46
+ export declare const THEME_NAMES: readonly string[];
package/dist/themes.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Sampled out of `examples/reference/arch.png` rather than invented,
3
+ * so the benchmark render and the drawing it is measured against differ by
4
+ * geometry and typography alone. A container is a shade off the page and barely
5
+ * outlined; a leaf is the navy that carries the diagram's weight.
6
+ */
7
+ export const DARK_THEME = {
8
+ background: '#111111',
9
+ boxFill: '#191728',
10
+ boxStroke: '#4f5367',
11
+ containerFill: '#191920',
12
+ containerStroke: '#25242f',
13
+ text: '#d9d9d9',
14
+ mutedText: '#8b8b8b',
15
+ edge: '#5c5c7c',
16
+ // Both sampled off the reference's machine glyphs. Note that the reference
17
+ // gives each icon its own hue — the drive is gray, the laptop periwinkle, the
18
+ // workstation violet — which is a drawing tool's per-shape default and not a
19
+ // system. One pair for the whole set is the deliberate difference: an icon
20
+ // should read as part of the diagram's palette, not as clip art dropped in.
21
+ iconInk: '#8d8d8e',
22
+ iconShade: '#3e3d58',
23
+ };
24
+ /** The dark theme's counterpart: the same roles, on a white page. */
25
+ const LIGHT_THEME = {
26
+ background: '#ffffff',
27
+ boxFill: '#eef0f7',
28
+ boxStroke: '#8a90a8',
29
+ containerFill: '#f6f7fa',
30
+ containerStroke: '#dcdfe7',
31
+ text: '#1f2328',
32
+ mutedText: '#6e7781',
33
+ edge: '#7c83a0',
34
+ iconInk: '#57606a',
35
+ iconShade: '#d6d9e6',
36
+ };
37
+ /**
38
+ * Every theme a file may name, in the order they are offered. Pairs sit
39
+ * together, dark first, and the two that have no light half follow them.
40
+ */
41
+ export const THEMES = {
42
+ dark: DARK_THEME,
43
+ light: LIGHT_THEME,
44
+ // Solarized, Ethan Schoonover. base03 page, base02 leaves, blue lines.
45
+ 'solarized-dark': {
46
+ background: '#002b36',
47
+ boxFill: '#073642',
48
+ boxStroke: '#586e75',
49
+ containerFill: '#03313c',
50
+ containerStroke: '#0b3f4c',
51
+ text: '#93a1a1',
52
+ mutedText: '#657b83',
53
+ edge: '#268bd2',
54
+ iconInk: '#839496',
55
+ iconShade: '#0f4a58',
56
+ },
57
+ // base3 page, base2 leaves, the same blue.
58
+ 'solarized-light': {
59
+ background: '#fdf6e3',
60
+ boxFill: '#eee8d5',
61
+ boxStroke: '#93a1a1',
62
+ containerFill: '#f6efdc',
63
+ containerStroke: '#e3dcc7',
64
+ text: '#586e75',
65
+ mutedText: '#93a1a1',
66
+ edge: '#268bd2',
67
+ iconInk: '#657b83',
68
+ iconShade: '#e0d9c3',
69
+ },
70
+ // Gruvbox, Pavel Pertsev. bg0 page, bg1 leaves, the warm yellow for lines.
71
+ 'gruvbox-dark': {
72
+ background: '#282828',
73
+ boxFill: '#3c3836',
74
+ boxStroke: '#665c54',
75
+ containerFill: '#32302f',
76
+ containerStroke: '#3c3836',
77
+ text: '#ebdbb2',
78
+ mutedText: '#a89984',
79
+ edge: '#d79921',
80
+ iconInk: '#a89984',
81
+ iconShade: '#504945',
82
+ },
83
+ 'gruvbox-light': {
84
+ background: '#fbf1c7',
85
+ boxFill: '#ebdbb2',
86
+ boxStroke: '#bdae93',
87
+ containerFill: '#f2e5bc',
88
+ containerStroke: '#e5d4a7',
89
+ text: '#3c3836',
90
+ mutedText: '#7c6f64',
91
+ edge: '#b57614',
92
+ iconInk: '#7c6f64',
93
+ iconShade: '#d5c4a1',
94
+ },
95
+ // Catppuccin. Mocha's base page and surface leaves, blue lines.
96
+ 'catppuccin-mocha': {
97
+ background: '#1e1e2e',
98
+ boxFill: '#313244',
99
+ boxStroke: '#6c7086',
100
+ containerFill: '#25253a',
101
+ containerStroke: '#313244',
102
+ text: '#cdd6f4',
103
+ mutedText: '#9399b2',
104
+ edge: '#89b4fa',
105
+ iconInk: '#a6adc8',
106
+ iconShade: '#45475a',
107
+ },
108
+ // Latte's base page and crust leaves, lavender lines.
109
+ 'catppuccin-latte': {
110
+ background: '#eff1f5',
111
+ boxFill: '#dce0e8',
112
+ boxStroke: '#9ca0b0',
113
+ containerFill: '#e6e9ef',
114
+ containerStroke: '#ccd0da',
115
+ text: '#4c4f69',
116
+ mutedText: '#7c7f93',
117
+ edge: '#7287fd',
118
+ iconInk: '#6c6f85',
119
+ iconShade: '#ccd0da',
120
+ },
121
+ // Nord, Sven Greb. Polar Night page and leaves, Frost lines.
122
+ nord: {
123
+ background: '#2e3440',
124
+ boxFill: '#3b4252',
125
+ boxStroke: '#4c566a',
126
+ containerFill: '#333a47',
127
+ containerStroke: '#3b4252',
128
+ text: '#d8dee9',
129
+ mutedText: '#7b88a1',
130
+ edge: '#81a1c1',
131
+ iconInk: '#aeb7c6',
132
+ iconShade: '#434c5e',
133
+ },
134
+ // Dracula, the free palette. Current-line leaves, comment borders, purple lines.
135
+ dracula: {
136
+ background: '#282a36',
137
+ boxFill: '#44475a',
138
+ boxStroke: '#6272a4',
139
+ containerFill: '#21222c',
140
+ containerStroke: '#343746',
141
+ text: '#f8f8f2',
142
+ mutedText: '#6272a4',
143
+ edge: '#bd93f9',
144
+ iconInk: '#b6b9cc',
145
+ iconShade: '#565a70',
146
+ },
147
+ // For low vision and projectors: no fills to lean on, every line at full
148
+ // strength, and a container told apart by a gray outline alone.
149
+ 'high-contrast-dark': {
150
+ background: '#000000',
151
+ boxFill: '#000000',
152
+ boxStroke: '#ffffff',
153
+ containerFill: '#000000',
154
+ containerStroke: '#9a9a9a',
155
+ text: '#ffffff',
156
+ mutedText: '#c8c8c8',
157
+ edge: '#ffffff',
158
+ iconInk: '#ffffff',
159
+ iconShade: '#3a3a3a',
160
+ },
161
+ 'high-contrast-light': {
162
+ background: '#ffffff',
163
+ boxFill: '#ffffff',
164
+ boxStroke: '#000000',
165
+ containerFill: '#ffffff',
166
+ containerStroke: '#6a6a6a',
167
+ text: '#000000',
168
+ mutedText: '#3d3d3d',
169
+ edge: '#000000',
170
+ iconInk: '#000000',
171
+ iconShade: '#d0d0d0',
172
+ },
173
+ // For paper: no fill anywhere an ink cartridge would notice, black lines,
174
+ // gray only where the dark theme is quiet.
175
+ print: {
176
+ background: '#ffffff',
177
+ boxFill: '#ffffff',
178
+ boxStroke: '#000000',
179
+ containerFill: '#ffffff',
180
+ containerStroke: '#8c8c8c',
181
+ text: '#000000',
182
+ mutedText: '#666666',
183
+ edge: '#333333',
184
+ iconInk: '#000000',
185
+ iconShade: '#ffffff',
186
+ },
187
+ };
188
+ /** The theme a file gets when it names none. */
189
+ export const DEFAULT_THEME = 'dark';
190
+ export const THEME_NAMES = Object.keys(THEMES);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {