reladraw 0.22.1 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -1231,7 +1231,7 @@ function takeTitlePlacements(tokens, open, subject, line) {
1231
1231
  * is the text.
1232
1232
  */
1233
1233
  function parseNode(head, line) {
1234
- const name = requireName(head[1], 'node', line);
1234
+ const name = requireName(head[1], 'node', line, head[2]);
1235
1235
  const written = head[2];
1236
1236
  const textToken = written?.quoted ? written : undefined;
1237
1237
  // A bare word here is a text somebody forgot to quote far more often than
@@ -1727,7 +1727,7 @@ function parseGuide(head, line) {
1727
1727
  * between two boxes, which copies of one line share.
1728
1728
  */
1729
1729
  function parseBundle(head, line) {
1730
- const name = requireName(head[1], 'bundle', line);
1730
+ const name = requireName(head[1], 'bundle', line, head[2]);
1731
1731
  const subject = `bundle "${name}"`;
1732
1732
  const attrs = {};
1733
1733
  let via;
@@ -1838,7 +1838,7 @@ function parseBundle(head, line) {
1838
1838
  }
1839
1839
  /** `point funnel between c2 and api at 2/3` */
1840
1840
  function parsePoint(head, line) {
1841
- const name = requireName(head[1], 'point', line);
1841
+ const name = requireName(head[1], 'point', line, head[2]);
1842
1842
  const subject = `point "${name}"`;
1843
1843
  const marked = readMarkText(head, 'point', subject, line);
1844
1844
  head = marked.tokens;
@@ -1857,7 +1857,7 @@ function parsePoint(head, line) {
1857
1857
  }
1858
1858
  /** `region r c bottom-left to d top-right`, or `region r between a and b (span: both)` */
1859
1859
  function parseRegion(head, line) {
1860
- const name = requireName(head[1], 'region', line);
1860
+ const name = requireName(head[1], 'region', line, head[2]);
1861
1861
  const subject = `region "${name}"`;
1862
1862
  const marked = readMarkText(head, 'region', subject, line);
1863
1863
  head = marked.tokens;
@@ -1904,7 +1904,7 @@ function refuseKeys(attrs, keys, subject, what, line) {
1904
1904
  }
1905
1905
  /** `style <name> <attributes>` */
1906
1906
  function parseStyle(head, line) {
1907
- const name = requireName(head[1], 'style', line);
1907
+ const name = requireName(head[1], 'style', line, head[2]);
1908
1908
  const attrs = attrsOnly(head, 2, line, `style "${name}"`);
1909
1909
  if (Object.keys(attrs).length === 0) {
1910
1910
  throw new SourceError(`style "${name}" sets nothing`, line);
@@ -1918,14 +1918,56 @@ 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
1924
1960
  * resolved, since only the caller knows whether files can be read at all.
1925
1961
  */
1926
1962
  function parseIcon(head, line) {
1927
- const name = requireName(head[1], 'icon', line);
1963
+ const name = requireName(head[1], 'icon', line, head[2]);
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
  }
@@ -2047,11 +2089,18 @@ function defaultRefusal(kind, key) {
2047
2089
  }
2048
2090
  return `which a default does not set — ${takes}`;
2049
2091
  }
2050
- function requireName(token, keyword, line) {
2092
+ function requireName(token, keyword, line, next) {
2051
2093
  if (!token || token.quoted) {
2052
2094
  throw new SourceError(`${keyword} needs a name`, line);
2053
2095
  }
2054
2096
  refuseNameMark(token, `${keyword} "${token.joined ?? token.text}"`, line);
2097
+ // A comma separates the items of a list (0.19.0), so `node a,b` reads as
2098
+ // the name `a` and a stray `b`. Say so, rather than whatever `b` turns into.
2099
+ if (token.text.endsWith(',')) {
2100
+ const touching = next && !next.quoted && next.start !== undefined && next.start === token.end;
2101
+ const written = touching ? `${token.text}${next.text}` : token.text;
2102
+ throw new SourceError(`${keyword} "${written}": a name may not contain ",", because a comma separates the items of a list`, line);
2103
+ }
2055
2104
  return token.text;
2056
2105
  }
2057
2106
  /**
@@ -2460,6 +2509,113 @@ function checkColorWord(value, subject, line) {
2460
2509
  throw new SourceError(`${subject}: \`${value}\` is not a color — ${fix} \`#${value}\``, line);
2461
2510
  }
2462
2511
  }
2512
+ const URL_QUOTES = 'a url is written in quotes — `url: "https://example.com"`. Without them everything from the `//` onwards is read as a comment';
2513
+ /**
2514
+ * `url: "https://a" (name: "A", icon: "A" (fill: red)) "https://b"`: one or
2515
+ * more quoted addresses, each with an optional bracket after it. The values in
2516
+ * a link's bracket are a name (a text, so quoted) and a badge, which is quoted
2517
+ * letters or the unquoted name of a picture. `readBracket` takes only unquoted
2518
+ * values and no bracket inside a bracket, so links are read here.
2519
+ */
2520
+ function readLinks(tokens, start, subject, line) {
2521
+ // `url: https://example.com` loses everything from the `//` onwards, because
2522
+ // `//` opens a comment — so the value is either missing entirely or is the
2523
+ // bare scheme, which reads as another attribute key. Neither report says
2524
+ // what is wrong, and the remedy is punctuation rather than a missing word.
2525
+ if (!tokens[start]?.quoted)
2526
+ throw new SourceError(`${subject}: ${URL_QUOTES}`, line);
2527
+ const links = [];
2528
+ let i = start;
2529
+ while (tokens[i]?.quoted) {
2530
+ const link = { href: tokens[i].text };
2531
+ i += 1;
2532
+ if (follows(tokens, i, '(')) {
2533
+ const read = readLinkBracket(tokens, i, link.href, subject, line);
2534
+ Object.assign(link, read.link);
2535
+ i = read.next;
2536
+ }
2537
+ if (links.some((had) => had.href === link.href)) {
2538
+ throw new SourceError(`${subject}: "${link.href}" is linked twice in one \`url:\` — keep one`, line);
2539
+ }
2540
+ links.push(link);
2541
+ }
2542
+ return { links, next: i };
2543
+ }
2544
+ function readLinkBracket(tokens, start, href, subject, line) {
2545
+ const link = {};
2546
+ const what = `the link "${href}"`;
2547
+ let i = start + 1;
2548
+ for (;;) {
2549
+ if (follows(tokens, i, ',')) {
2550
+ i += 1;
2551
+ continue;
2552
+ }
2553
+ if (follows(tokens, i, ')'))
2554
+ break;
2555
+ const keyToken = tokens[i];
2556
+ if (!keyToken)
2557
+ throw new SourceError(`${subject}: ${what} opens a "(" and never closes it`, line);
2558
+ if (!isAttrKey(keyToken)) {
2559
+ throw new SourceError(`${subject}: ${what} takes \`name:\` and \`icon:\` in its brackets, found "${keyToken.text}"`, line);
2560
+ }
2561
+ const key = keyToken.text.slice(0, -1);
2562
+ if (key !== 'name' && key !== 'icon') {
2563
+ throw new SourceError(`a link has no "${key}" — it takes name and icon`, line);
2564
+ }
2565
+ if (link[key] !== undefined) {
2566
+ throw new SourceError(`${subject}: "${key}" is written twice in the brackets after ${what} — keep one`, line);
2567
+ }
2568
+ const value = tokens[i + 1];
2569
+ if (!value || isAttrKey(value) || (!value.quoted && (value.text === ')' || value.text === '('))) {
2570
+ throw new SourceError(`${subject}: "${key}" has no value`, line);
2571
+ }
2572
+ if (key === 'name') {
2573
+ if (!value.quoted) {
2574
+ throw new SourceError(`${subject}: a link's name is a text, written in quotes — \`name: "${value.text.replace(/,$/, '')}"\``, line);
2575
+ }
2576
+ link.name = value.text;
2577
+ i += 2;
2578
+ continue;
2579
+ }
2580
+ // A badge: quoted letters, which may take their colors in a bracket of
2581
+ // their own, or the name of a picture, which keeps the colors it has.
2582
+ if (value.quoted) {
2583
+ if (value.text.trim() === '')
2584
+ throw new SourceError(`${subject}: a link's letter badge has no letters — write \`icon: "GH"\``, line);
2585
+ const icon = { letters: value.text };
2586
+ i += 2;
2587
+ if (follows(tokens, i, '(')) {
2588
+ const read = readBracket(tokens, i, ['fill', 'text'], {
2589
+ subject,
2590
+ what: `the badge "${value.text}"`,
2591
+ kind: 'a letter badge',
2592
+ example: 'fill: #24292f',
2593
+ line,
2594
+ });
2595
+ if (Object.keys(read.values).length === 0) {
2596
+ throw new SourceError(`${subject}: the badge "${value.text}" opens empty brackets`, line);
2597
+ }
2598
+ for (const [color, set] of Object.entries(read.values)) {
2599
+ checkColorWord(set, subject, line);
2600
+ icon[color] = set;
2601
+ }
2602
+ i = read.next;
2603
+ }
2604
+ link.icon = icon;
2605
+ continue;
2606
+ }
2607
+ const picture = value.text.replace(/,$/, '');
2608
+ i += 2;
2609
+ if (follows(tokens, i, '(')) {
2610
+ throw new SourceError(`${subject}: the picture "${picture}" keeps its own colors, so it takes no bracket — ` +
2611
+ `letters do: \`icon: "${picture.slice(0, 2).toUpperCase()}" (fill: …)\``, line);
2612
+ }
2613
+ link.icon = { picture };
2614
+ }
2615
+ if (Object.keys(link).length === 0)
2616
+ throw new SourceError(`${subject}: ${what} opens empty brackets`, line);
2617
+ return { link, next: i + 1 };
2618
+ }
2463
2619
  function follows(tokens, at, word) {
2464
2620
  const token = tokens[at];
2465
2621
  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);
@@ -0,0 +1,104 @@
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;