reladraw 0.22.0 → 0.23.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/parser.js CHANGED
@@ -3,6 +3,7 @@ import { CHECK_DEFAULTS, CHECK_KINDS, setCheck } from './checks.js';
3
3
  import { SourceError } from './errors.js';
4
4
  import { STATEMENT_KEYWORDS } from './grammar.js';
5
5
  import { isAttrKey, nameMark, OpenString, tokenizeLine } from './lexer.js';
6
+ import { LINKS_KEY } from './links.js';
6
7
  import { SHAPE_SETTINGS } from './outline.js';
7
8
  import { checkOpacity } from './place.js';
8
9
  /** The settings a shape's bracket takes as a word on its own: `cylinder (lying)`. */
@@ -711,17 +712,16 @@ function readAttr(tokens, at, attrs, line, subject) {
711
712
  return read.next;
712
713
  }
713
714
  if (key === 'url') {
714
- // `url: https://example.com` loses everything from the `//` onwards, because
715
- // `//` opens a comment — so the value is either missing entirely or is the
716
- // bare scheme, which reads as another attribute key. Neither report says
717
- // what is wrong, and the remedy is punctuation rather than a missing word.
718
- const value = tokens[at + 1];
719
- if (!value || !value.quoted) {
720
- throw new SourceError(`${subject}: a url is written in quotes — \`url: "https://example.com"\`. Without them ` +
721
- 'everything from the `//` onwards is read as a comment', line);
715
+ // Several links are several addresses after the one key, as `cards:` lists
716
+ // its cards, each named in a bracket after it. The first stays under `url`,
717
+ // so whatever follows one link follows the first of several; the whole
718
+ // list is kept beside it only when there is more than a bare address.
719
+ const read = readLinks(tokens, at + 1, subject, line);
720
+ setOnce(attrs, key, read.links[0].href, subject, line, { key, value: quoteOf });
721
+ if (read.links.length > 1 || read.links.some((link) => link.name !== undefined || link.icon !== undefined)) {
722
+ attrs[LINKS_KEY] = JSON.stringify(read.links);
722
723
  }
723
- setOnce(attrs, key, value.text, subject, line, { key, value: quoteOf });
724
- return at + 2;
724
+ return read.next;
725
725
  }
726
726
  if (key === 'title') {
727
727
  // A title is a text, and every text is quoted. Unquoted, its first word
@@ -1918,6 +1918,42 @@ function parseStyle(head, line) {
1918
1918
  }
1919
1919
  return { kind: 'style', name, attrs, line };
1920
1920
  }
1921
+ /**
1922
+ * `icon gh letters: "GH" fill: #24292f text: white`. The characters have a
1923
+ * key of their own because a quoted string after an icon's name is a file to
1924
+ * read, and `text:` is the letters' color, as it is a node's.
1925
+ */
1926
+ function readLetterIcon(head, start, name, line) {
1927
+ const read = {};
1928
+ const subject = `icon "${name}"`;
1929
+ for (let i = start; i < head.length; i += 2) {
1930
+ const keyToken = head[i];
1931
+ clauseStarts?.add(keyToken);
1932
+ const key = keyToken.text.slice(0, -1);
1933
+ if (!isAttrKey(keyToken) || (key !== 'letters' && key !== 'fill' && key !== 'text')) {
1934
+ throw new SourceError(`${subject}: a letter icon takes letters, fill and text, found "${keyToken.text}"`, line);
1935
+ }
1936
+ if (read[key] !== undefined)
1937
+ throw new SourceError(`${subject}: "${key}" is written twice — keep one`, line);
1938
+ const value = head[i + 1];
1939
+ if (value === undefined || isAttrKey(value))
1940
+ throw new SourceError(`${subject}: "${key}" has no value`, line);
1941
+ if (key === 'letters') {
1942
+ if (!value.quoted)
1943
+ throw new SourceError(`${subject}: letters are a text, written in quotes — \`letters: "${value.text}"\``, line);
1944
+ if (value.text.trim() === '')
1945
+ throw new SourceError(`${subject}: \`letters:\` is empty — write the letters to show, such as \`letters: "GH"\``, line);
1946
+ read.letters = value.text;
1947
+ }
1948
+ else {
1949
+ if (value.quoted)
1950
+ throw new SourceError(`${subject}: a color is written without quotes — \`${key}: ${value.text}\``, line);
1951
+ checkColorWord(value.text, subject, line);
1952
+ read[key] = value.text;
1953
+ }
1954
+ }
1955
+ return { letters: read.letters, ...(read.fill === undefined ? {} : { fill: read.fill }), ...(read.text === undefined ? {} : { text: read.text }) };
1956
+ }
1921
1957
  /**
1922
1958
  * `icon <name> "<svg>…</svg>"`, or `icon <name> ./file.svg`. What the picture
1923
1959
  * is — pasted markup or a file to read — is worked out when the diagram is
@@ -1926,6 +1962,12 @@ function parseStyle(head, line) {
1926
1962
  function parseIcon(head, line) {
1927
1963
  const name = requireName(head[1], 'icon', line);
1928
1964
  const picture = head[2];
1965
+ if (picture !== undefined && isAttrKey(picture) && picture.text === 'letters:') {
1966
+ return { kind: 'icon', name, source: '', pasted: false, letters: readLetterIcon(head, 2, name, line), line };
1967
+ }
1968
+ if (picture !== undefined && isAttrKey(picture) && (picture.text === 'fill:' || picture.text === 'text:')) {
1969
+ throw new SourceError(`icon "${name}" has colors and no letters — a letter icon starts \`icon ${name} letters: "GH"\``, line);
1970
+ }
1929
1971
  if (picture === undefined || isAttrKey(picture)) {
1930
1972
  throw new SourceError(`icon "${name}" needs its picture: the SVG itself between """ marks, or the name of an .svg file`, line);
1931
1973
  }
@@ -2460,6 +2502,113 @@ function checkColorWord(value, subject, line) {
2460
2502
  throw new SourceError(`${subject}: \`${value}\` is not a color — ${fix} \`#${value}\``, line);
2461
2503
  }
2462
2504
  }
2505
+ const URL_QUOTES = 'a url is written in quotes — `url: "https://example.com"`. Without them everything from the `//` onwards is read as a comment';
2506
+ /**
2507
+ * `url: "https://a" (name: "A", icon: "A" (fill: red)) "https://b"`: one or
2508
+ * more quoted addresses, each with an optional bracket after it. The values in
2509
+ * a link's bracket are a name (a text, so quoted) and a badge, which is quoted
2510
+ * letters or the unquoted name of a picture. `readBracket` takes only unquoted
2511
+ * values and no bracket inside a bracket, so links are read here.
2512
+ */
2513
+ function readLinks(tokens, start, subject, line) {
2514
+ // `url: https://example.com` loses everything from the `//` onwards, because
2515
+ // `//` opens a comment — so the value is either missing entirely or is the
2516
+ // bare scheme, which reads as another attribute key. Neither report says
2517
+ // what is wrong, and the remedy is punctuation rather than a missing word.
2518
+ if (!tokens[start]?.quoted)
2519
+ throw new SourceError(`${subject}: ${URL_QUOTES}`, line);
2520
+ const links = [];
2521
+ let i = start;
2522
+ while (tokens[i]?.quoted) {
2523
+ const link = { href: tokens[i].text };
2524
+ i += 1;
2525
+ if (follows(tokens, i, '(')) {
2526
+ const read = readLinkBracket(tokens, i, link.href, subject, line);
2527
+ Object.assign(link, read.link);
2528
+ i = read.next;
2529
+ }
2530
+ if (links.some((had) => had.href === link.href)) {
2531
+ throw new SourceError(`${subject}: "${link.href}" is linked twice in one \`url:\` — keep one`, line);
2532
+ }
2533
+ links.push(link);
2534
+ }
2535
+ return { links, next: i };
2536
+ }
2537
+ function readLinkBracket(tokens, start, href, subject, line) {
2538
+ const link = {};
2539
+ const what = `the link "${href}"`;
2540
+ let i = start + 1;
2541
+ for (;;) {
2542
+ if (follows(tokens, i, ',')) {
2543
+ i += 1;
2544
+ continue;
2545
+ }
2546
+ if (follows(tokens, i, ')'))
2547
+ break;
2548
+ const keyToken = tokens[i];
2549
+ if (!keyToken)
2550
+ throw new SourceError(`${subject}: ${what} opens a "(" and never closes it`, line);
2551
+ if (!isAttrKey(keyToken)) {
2552
+ throw new SourceError(`${subject}: ${what} takes \`name:\` and \`icon:\` in its brackets, found "${keyToken.text}"`, line);
2553
+ }
2554
+ const key = keyToken.text.slice(0, -1);
2555
+ if (key !== 'name' && key !== 'icon') {
2556
+ throw new SourceError(`a link has no "${key}" — it takes name and icon`, line);
2557
+ }
2558
+ if (link[key] !== undefined) {
2559
+ throw new SourceError(`${subject}: "${key}" is written twice in the brackets after ${what} — keep one`, line);
2560
+ }
2561
+ const value = tokens[i + 1];
2562
+ if (!value || isAttrKey(value) || (!value.quoted && (value.text === ')' || value.text === '('))) {
2563
+ throw new SourceError(`${subject}: "${key}" has no value`, line);
2564
+ }
2565
+ if (key === 'name') {
2566
+ if (!value.quoted) {
2567
+ throw new SourceError(`${subject}: a link's name is a text, written in quotes — \`name: "${value.text.replace(/,$/, '')}"\``, line);
2568
+ }
2569
+ link.name = value.text;
2570
+ i += 2;
2571
+ continue;
2572
+ }
2573
+ // A badge: quoted letters, which may take their colors in a bracket of
2574
+ // their own, or the name of a picture, which keeps the colors it has.
2575
+ if (value.quoted) {
2576
+ if (value.text.trim() === '')
2577
+ throw new SourceError(`${subject}: a link's letter badge has no letters — write \`icon: "GH"\``, line);
2578
+ const icon = { letters: value.text };
2579
+ i += 2;
2580
+ if (follows(tokens, i, '(')) {
2581
+ const read = readBracket(tokens, i, ['fill', 'text'], {
2582
+ subject,
2583
+ what: `the badge "${value.text}"`,
2584
+ kind: 'a letter badge',
2585
+ example: 'fill: #24292f',
2586
+ line,
2587
+ });
2588
+ if (Object.keys(read.values).length === 0) {
2589
+ throw new SourceError(`${subject}: the badge "${value.text}" opens empty brackets`, line);
2590
+ }
2591
+ for (const [color, set] of Object.entries(read.values)) {
2592
+ checkColorWord(set, subject, line);
2593
+ icon[color] = set;
2594
+ }
2595
+ i = read.next;
2596
+ }
2597
+ link.icon = icon;
2598
+ continue;
2599
+ }
2600
+ const picture = value.text.replace(/,$/, '');
2601
+ i += 2;
2602
+ if (follows(tokens, i, '(')) {
2603
+ throw new SourceError(`${subject}: the picture "${picture}" keeps its own colors, so it takes no bracket — ` +
2604
+ `letters do: \`icon: "${picture.slice(0, 2).toUpperCase()}" (fill: …)\``, line);
2605
+ }
2606
+ link.icon = { picture };
2607
+ }
2608
+ if (Object.keys(link).length === 0)
2609
+ throw new SourceError(`${subject}: ${what} opens empty brackets`, line);
2610
+ return { link, next: i + 1 };
2611
+ }
2463
2612
  function follows(tokens, at, word) {
2464
2613
  const token = tokens[at];
2465
2614
  return token !== undefined && !token.quoted && token.text === word;
package/dist/render.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type Icon, type LetterIcon } from './icons.js';
1
2
  import { type Measurer } from './measure.js';
2
3
  import { type Layout } from './model.js';
3
4
  import { type Theme } from './themes.js';
@@ -27,3 +28,15 @@ export declare function renderTagged(layout: Layout, options?: RenderOptions): {
27
28
  * they are folded in afterwards and everything downstream sees one theme.
28
29
  */
29
30
  export declare function themeOf(layout: Layout, options?: RenderOptions): Theme;
31
+ /** An icon on its own, as a small SVG of `side` pixels: what the page puts beside a link. */
32
+ export declare function iconSvg(icon: Icon, side: number, theme: Theme): string;
33
+ /**
34
+ * A letter badge's circle and letters. Unsaid, the circle is one of the
35
+ * theme's two accents, picked from the letters so the same letters always get
36
+ * the same one, and the letters are that accent's own text color; a stated
37
+ * fill with no text color takes the theme's text color.
38
+ */
39
+ export declare function letterColors(icon: LetterIcon, theme: Theme): {
40
+ fill: string;
41
+ text: string;
42
+ };
package/dist/render.js CHANGED
@@ -8,6 +8,7 @@ import { DARK_THEME, THEMES, overlapFill, regionTint, textOnFill, themeColor } f
8
8
  import { crossingPoints, planLines, traceRoute, visibleRuns } from './lines.js';
9
9
  import { patchPadding, placeLineTexts } from './linetexts.js';
10
10
  import { plain } from './text.js';
11
+ import { linkLabel, linksOf } from './links.js';
11
12
  const CORNER = 8;
12
13
  /** Turn solved geometry into a standalone SVG document. */
13
14
  export function render(layout, options = {}) {
@@ -104,7 +105,10 @@ function drawing(layout, options) {
104
105
  edge.ink = union(edge.ink, grow(extentOfPoints([crossing.at]), crossing.reach + edge.edge.look.thickness));
105
106
  }
106
107
  }
107
- lines.push({ key: keyOf(edge), svg: linked(parts.join('\n'), edge.edge.attrs['url']) });
108
+ const links = linksOf(edge.edge.attrs);
109
+ if (links !== undefined && edge.edge.texts.length === 0)
110
+ parts.push(lineTarget(edge.line));
111
+ lines.push({ key: keyOf(edge), svg: linked(parts.join('\n'), links) });
108
112
  ink = union(ink, grow(edge.ink, layout.margin));
109
113
  }
110
114
  const lineSvg = new Map(lines.map((line) => [line.key, line.svg]));
@@ -196,7 +200,13 @@ function tag(layout, item, svg) {
196
200
  return svg;
197
201
  ({ name, line } = mark);
198
202
  }
199
- return `<g data-name="${escapeXml(name).replace(/"/g, '&quot;')}" data-line="${line}">\n${svg}\n</g>`;
203
+ // What the page selects: a node by its name, a line by its place in the list.
204
+ const what = item.node ? ` data-node="${escapeXml(item.node.name).replace(/"/g, '&quot;')}"` : item.key.startsWith('edge ') ? ` data-edge="${item.key.slice('edge '.length)}"` : '';
205
+ return `<g data-name="${escapeXml(name).replace(/"/g, '&quot;')}" data-line="${line}"${what}>\n${svg}\n</g>`;
206
+ }
207
+ /** An icon on its own, as a small SVG of `side` pixels: what the page puts beside a link. */
208
+ export function iconSvg(icon, side, theme) {
209
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${side}" height="${side}" viewBox="0 0 ${side} ${side}" font-family="system-ui, sans-serif">\n${drawIcon(icon, 0, 0, side, theme)}\n</svg>`;
200
210
  }
201
211
  /**
202
212
  * Gives every id in a drawing a prefix of its own, taken from a hash of the
@@ -254,12 +264,28 @@ function ownIds(svg) {
254
264
  * have given — the container catches every click its children do not — reached
255
265
  * by repeating the destination rather than by wrapping.
256
266
  */
257
- function linked(svg, url) {
258
- if (url === undefined || svg.length === 0)
267
+ function linked(svg, links) {
268
+ const first = links?.[0];
269
+ if (first === undefined || svg.length === 0)
259
270
  return svg;
271
+ // A plain SVG can follow one address, so a click follows the first; the
272
+ // tooltip names every link, so a reader learns there are others. A single
273
+ // bare address gets no tooltip, so a file written before several links
274
+ // draws exactly as it did.
275
+ const named = links.length > 1 || first.name !== undefined;
276
+ const title = named ? `\n <title>${escapeXml(links.map((link) => linkLabel(link, links)).join('\n'))}</title>` : '';
260
277
  // An `&` between query parameters is ordinary in a url and illegal raw in an
261
278
  // attribute, so the value is escaped as markup rather than merely quoted.
262
- return ` <a href=${quote(escapeXml(url))} target="_blank" rel="noopener">\n${svg}\n </a>`;
279
+ return ` <a href=${quote(escapeXml(first.href))} target="_blank" rel="noopener">${title}\n${svg}\n </a>`;
280
+ }
281
+ /**
282
+ * A line with a link and no text is its own target, and a line is too thin to
283
+ * hit, so an unseen wider copy of its path catches the click.
284
+ */
285
+ function lineTarget(line) {
286
+ const straight = line.match(/ x1="([^"]*)" y1="([^"]*)" x2="([^"]*)" y2="([^"]*)"/);
287
+ const d = straight ? `M${straight[1]} ${straight[2]} L${straight[3]} ${straight[4]}` : line.match(/ d="([^"]*)"/)?.[1];
288
+ return d === undefined ? '' : ` <path d="${d}" fill="none" stroke="#000" stroke-opacity="0" stroke-width="12" pointer-events="stroke"/>`;
263
289
  }
264
290
  /** Whether a node holds anything besides its heading and what is joined to its text: what a theme draws as a container. */
265
291
  function holdsContents(node) {
@@ -314,7 +340,7 @@ const GUIDE_DASH = '4 3';
314
340
  * a destination never encloses a child's.
315
341
  */
316
342
  function drawNode(node, theme, measurer, fontSize, markup, guides, inherited) {
317
- const url = node.attrs['url'] ?? inherited;
343
+ const url = linksOf(node.attrs) ?? inherited;
318
344
  let own = [];
319
345
  if (!node.guide) {
320
346
  own = nodeSvg(node, theme, measurer, fontSize, markup);
@@ -383,9 +409,9 @@ function drawVenn(node, theme, measurer, fontSize, markup, guides, url) {
383
409
  const leftFill = left.appearance['fill'] ?? 'theme-primary-subtle';
384
410
  const rightFill = right.appearance['fill'] ?? 'theme-secondary-subtle';
385
411
  const sharedFill = shared.appearance['fill'] ?? overlapFill(themeColor(leftFill, theme), theme);
386
- const item = (part, key, svg, fills = []) => ({ key, node: part, svg: linked(svg.join('\n'), part.attrs['url'] ?? url), fills });
412
+ const item = (part, key, svg, fills = []) => ({ key, node: part, svg: linked(svg.join('\n'), linksOf(part.attrs) ?? url), fills });
387
413
  const filled = (part, fill) => item(part, part.name, nodeSvg(plain(part, fill), theme, measurer, fontSize, markup), nodeFills(plain(part, fill), theme));
388
- const rest = (part) => part.children.flatMap((child) => drawNode(child, theme, measurer, fontSize, markup, guides, part.attrs['url'] ?? url));
414
+ const rest = (part) => part.children.flatMap((child) => drawNode(child, theme, measurer, fontSize, markup, guides, linksOf(part.attrs) ?? url));
389
415
  return [
390
416
  { key: node.name, node, svg: linked(own.join('\n'), url), fills: nodeFills(plain(node, node.appearance['fill'] ?? 'none'), theme) },
391
417
  filled(left, leftFill),
@@ -781,8 +807,31 @@ function outlineDetail(shape, x, y, w, h, look = {}) {
781
807
  return [];
782
808
  return shapeOf(shape, look, w, h).detail.map((d) => placePath(d, x, y));
783
809
  }
810
+ /**
811
+ * A letter badge's circle and letters. Unsaid, the circle is one of the
812
+ * theme's two accents, picked from the letters so the same letters always get
813
+ * the same one, and the letters are that accent's own text color; a stated
814
+ * fill with no text color takes the theme's text color.
815
+ */
816
+ export function letterColors(icon, theme) {
817
+ const sum = [...icon.letters].reduce((total, char) => total + char.codePointAt(0), 0);
818
+ const accent = sum % 2 === 0 ? theme.primary : theme.secondary;
819
+ const fill = icon.fill === undefined ? accent.color : themeColor(icon.fill, theme);
820
+ const text = icon.text !== undefined ? themeColor(icon.text, theme) : icon.fill === undefined ? accent.on : theme.text;
821
+ return { fill, text };
822
+ }
784
823
  /** One icon, scaled from its own grid onto a square of `side` at `x, y`. */
785
824
  function drawIcon(icon, x, y, side, theme) {
825
+ if ('letters' in icon) {
826
+ const colors = letterColors(icon, theme);
827
+ const r = side / 2;
828
+ // Letters shrink as there are more of them, so two or three still fit the circle.
829
+ const size = round(side * (icon.letters.length <= 1 ? 0.55 : icon.letters.length === 2 ? 0.42 : 0.32));
830
+ return [
831
+ ` <circle cx="${round(x + r)}" cy="${round(y + r)}" r="${round(r)}" fill="${colors.fill}"/>`,
832
+ ` <text x="${round(x + r)}" y="${round(y + r)}" text-anchor="middle" dominant-baseline="central" font-size="${size}px" font-weight="700" fill="${colors.text}">${escapeXml(icon.letters)}</text>`,
833
+ ].join('\n');
834
+ }
786
835
  if ('inner' in icon) {
787
836
  // Its own viewBox fits it to the square, centered and in proportion.
788
837
  // `color` is set only when the drawing does not set it, so `currentColor`
package/dist/resolve.js CHANGED
@@ -2,7 +2,8 @@ import { TEXT_GAP } from './linetexts.js';
2
2
  import { POINT_KEYS, REGION_KEYS, ALL_ATTR_KEYS, ATTR_KEYS, MARKS, COLOR_KEYS, COLOR_PARTS, CHILD_ALIGNMENTS, GATHER_PLACES, CONTENT_WIDTHS, SIZE_KEY_NAMES, LINE_VALUES, NODE_CORNERS, PADDING_SIDES, describePlacement, nameTarget, partWordHint, } from './ast.js';
3
3
  import { DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, ICON_GAP, ICON_LINES, LINE_WIDTH, PAD, THICKNESS, BORDER_THICKNESS, BORDER_WIDTH, fontSizeFor, widestLine, } from './constants.js';
4
4
  import { SourceError } from './errors.js';
5
- import { bodyFor, pastedIcon } from './icons.js';
5
+ import { bodyFor, iconNamed, pastedIcon } from './icons.js';
6
+ import { linksOf } from './links.js';
6
7
  import { monospaceMeasurer } from './measure.js';
7
8
  import { isBoxy, middleOf, sizeFor, SUBPROCESS_INSET } from './outline.js';
8
9
  import { markupStyles, parseMarkup, plain, splitRuns, wrapLine } from './text.js';
@@ -302,6 +303,18 @@ export function resolve(doc, options = {}) {
302
303
  checkStyleKeys(doc.statements);
303
304
  const defaults = collectDefaults(doc.statements, styles);
304
305
  const icons = collectIcons(doc.statements, options.readIconFile);
306
+ // A link's badge may name a picture, which has to be one the file can draw.
307
+ // The page draws badges, so the pictures they name ride along in the layout.
308
+ const linkIcons = {};
309
+ for (const stmt of doc.statements) {
310
+ if (!('attrs' in stmt) || stmt.attrs === undefined)
311
+ continue;
312
+ for (const link of linksOf(stmt.attrs) ?? []) {
313
+ if (link.icon !== undefined && 'picture' in link.icon) {
314
+ linkIcons[link.icon.picture] = iconNamed(link.icon.picture, stmt.line, icons);
315
+ }
316
+ }
317
+ }
305
318
  const { nodes, byName, roots } = buildTree(writeOutCards(doc.statements), styles, defaults, icons);
306
319
  // From here on, `edge each child of a -> b` and `edge a -> b, c` are the lines they stand for.
307
320
  const statements = expandEach(doc.statements, byName);
@@ -333,6 +346,7 @@ export function resolve(doc, options = {}) {
333
346
  roots,
334
347
  edges,
335
348
  markup: markupColors(nodes, edges, styles, marks),
349
+ linkIcons,
336
350
  diagram: collectDiagram(doc.statements),
337
351
  width: Math.ceil(extent.maxX + margin),
338
352
  height: Math.ceil(extent.maxY + margin),
@@ -592,6 +606,10 @@ function collectIcons(statements, readIconFile) {
592
606
  if (icons.has(stmt.name)) {
593
607
  throw new SourceError(`icon "${stmt.name}" is declared twice`, stmt.line);
594
608
  }
609
+ if (stmt.letters !== undefined) {
610
+ icons.set(stmt.name, stmt.letters);
611
+ continue;
612
+ }
595
613
  let svg = stmt.source.trim();
596
614
  if (!stmt.pasted) {
597
615
  if (readIconFile === undefined) {
@@ -1422,14 +1440,9 @@ function buildEdges(statements, byName, named, styles, defaults, measurer, fontS
1422
1440
  const what = `${stmt.from} ${stmt.arrow} ${stmt.to}`;
1423
1441
  checkAttrs('edge', what, stmt.attrs, stmt.line);
1424
1442
  checkStyleUse('edge', what, stmt.attrs, styles, stmt.line);
1425
- if (stmt.attrs['url'] !== undefined && stmt.texts.length === 0) {
1426
- // Wrapping the line works, and `LINE_WIDTH` is 1.5 — a destination that
1427
- // technically has a target and practically has none. That is the silent
1428
- // defect shape the language refuses everywhere else, so it is refused
1429
- // here rather than shipped and explained.
1430
- throw new SourceError(`edge ${what} has a url and no text. A destination needs something to click, and a line ` +
1431
- 'is too thin to be it — give the edge a text, or put the url on one of the nodes', stmt.line);
1432
- }
1443
+ // A line with a link and no text was once refused, as too thin to click.
1444
+ // The render now gives it an unseen wider target along its path, and the
1445
+ // page selects a line by the same, so it is accepted.
1433
1446
  const texts = stmt.texts.map(({ text, textAttrs: own, place }) => {
1434
1447
  const textAttrs = { ...bracketOf('text', appearance), ...own };
1435
1448
  const lines = linesFor(text, textAttrs, `edge ${what}`, stmt.line);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {
package/dist/search.d.ts DELETED
@@ -1,104 +0,0 @@
1
- /**
2
- * Finding a line's way through the open space between boxes.
3
- *
4
- * Every box is a wall. A line may turn only where two tracks cross, and the
5
- * tracks are few: a line a little way out from each side of each wall, one
6
- * down the middle of each gap between walls, and the lines through the two
7
- * ends. That is the whole of the open space a right-angled line ever needs,
8
- * reduced to a grid of a few hundred points for a diagram of ordinary size.
9
- *
10
- * The line leaves its start heading straight out and arrives at its end heading
11
- * straight in, with a straight stretch at each end at least `stub` long, so an
12
- * arrowhead always lands on a straight piece. Between those two stretches it
13
- * takes the cheapest way through the grid, where cost is length plus a charge
14
- * for every turn and for every line already placed that it crosses. On a
15
- * right-angled grid many ways tie on length — a staircase is exactly as long as
16
- * one bend — and the turn charge breaks those ties toward the fewest bends,
17
- * which is the line a person would draw. What still ties after that goes over
18
- * the top rather than under, and round the right rather than the left.
19
- *
20
- * A way through a box is not a losing option here. It is not a way at all.
21
- */
22
- export interface SearchPoint {
23
- x: number;
24
- y: number;
25
- }
26
- /** A box the line may not enter, as its extent. Touching its border is allowed. */
27
- export interface SearchWall {
28
- minX: number;
29
- minY: number;
30
- maxX: number;
31
- maxY: number;
32
- }
33
- /** One end of the line: a point on a box's side, and the unit direction out of it. */
34
- export interface SearchEnd {
35
- x: number;
36
- y: number;
37
- dx: number;
38
- dy: number;
39
- }
40
- export interface SearchOptions {
41
- /** How far the line runs straight out of each end before it may turn. */
42
- stub: number;
43
- /** How far outside a wall a track runs beside it, where there is room. */
44
- clear: number;
45
- /** What a turn costs, in the same units as length. */
46
- turn: number;
47
- /**
48
- * Walls the stretches at the two ends may pass through: the line's own boxes,
49
- * which it has to cross the margin of to leave, and any box holding an end.
50
- */
51
- ownStart: SearchWall[];
52
- ownEnd: SearchWall[];
53
- /**
54
- * Lines already placed. A line may cross one but not run along it within
55
- * `lane` of it; tracks are added a lane either side of each, so a later line
56
- * sharing a stretch takes the next lane out.
57
- */
58
- taken: SearchPoint[][];
59
- lane: number;
60
- /**
61
- * For each line in `taken`, for each of its stretches, half the depth across
62
- * it of a text riding there, and 0 where none does. A line running beside a
63
- * stretch carrying a text keeps clear of the text, not just of the line.
64
- */
65
- takenHalves?: number[][];
66
- /**
67
- * A gap the line must pass through: somewhere it crosses the line at `at`
68
- * on the `run` axis, travelling along `run`, at a level between `lo` and `hi`.
69
- */
70
- gate?: SearchGate;
71
- /**
72
- * Half the depth of this line's own text, which rides in the gate: inside it,
73
- * the line keeps that much further from the lines beside it.
74
- */
75
- ownHalf?: number;
76
- /**
77
- * How far the line may go at all: the diagram with room round it. A wall
78
- * reaching past this — a clause's fence, which runs to the edge of the page —
79
- * cannot be gone round at its far end.
80
- */
81
- bounds?: SearchWall;
82
- /**
83
- * A 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;
88
- }
89
- /** A gap a line must pass through. See `SearchOptions.gate`. */
90
- export interface SearchGate {
91
- run: 'x' | 'y';
92
- at: number;
93
- lo: number;
94
- hi: number;
95
- }
96
- export interface SearchResult {
97
- points: SearchPoint[];
98
- cost: number;
99
- }
100
- /**
101
- * The cheapest right-angled line from `start` to `end` that enters no wall, or
102
- * undefined when there is none.
103
- */
104
- export declare function searchRoute(start: SearchEnd, end: SearchEnd, walls: SearchWall[], options: SearchOptions): SearchResult | undefined;