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/.claude/skills/reladraw/SKILL.md +27 -6
- package/.claude/skills/reladraw/reference/syntax.md +40 -4
- package/README.md +3 -1
- package/SYNTAX.md +40 -4
- package/dist/ast.d.ts +6 -0
- package/dist/cli.js +12 -6
- package/dist/format.js +67 -20
- 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/search.d.ts +0 -104
- package/dist/search.js +0 -490
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);
|
package/package.json
CHANGED
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;
|