reladraw 0.22.1 → 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/.claude/skills/reladraw/SKILL.md +5 -5
- package/.claude/skills/reladraw/reference/syntax.md +33 -3
- package/README.md +3 -1
- package/SYNTAX.md +33 -3
- package/dist/ast.d.ts +6 -0
- package/dist/cli.js +4 -4
- package/dist/icons.d.ts +11 -1
- package/dist/links.d.ts +31 -0
- package/dist/links.js +34 -0
- package/dist/model.d.ts +3 -1
- package/dist/page.d.ts +8 -5
- package/dist/page.js +411 -49
- package/dist/parser.js +159 -10
- package/dist/render.d.ts +13 -0
- package/dist/render.js +57 -8
- package/dist/resolve.js +22 -9
- package/package.json +1 -1
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
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
const
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, '"')}"` : item.key.startsWith('edge ') ? ` data-edge="${item.key.slice('edge '.length)}"` : '';
|
|
205
|
+
return `<g data-name="${escapeXml(name).replace(/"/g, '"')}" 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,
|
|
258
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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);
|