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/page.js
CHANGED
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
import { fileChecks, findings } from './checks.js';
|
|
2
2
|
import { SourceError } from './errors.js';
|
|
3
3
|
import { blame, parse } from './parser.js';
|
|
4
|
-
import {
|
|
4
|
+
import { linkLabel, linksOf } from './links.js';
|
|
5
|
+
import { iconSvg, renderTagged, themeOf } from './render.js';
|
|
5
6
|
import { resolve } from './resolve.js';
|
|
6
7
|
import { luminance } from './themes.js';
|
|
7
8
|
import { PAN_ZOOM } from './viewer.js';
|
|
8
9
|
/**
|
|
9
10
|
* `reladraw render --page`: one diagram in one self-contained HTML page, made
|
|
10
11
|
* for looking at rather than embedding. The page is the diagram's own colors,
|
|
11
|
-
* headed with its title, and it pans, zooms, goes full screen, saves
|
|
12
|
-
* or a PNG, lists the findings, and names a box under the
|
|
13
|
-
* nothing running and fetches nothing, so it can be
|
|
14
|
-
* committed. `
|
|
12
|
+
* headed with its title, and it pans, zooms, goes full screen, saves itself,
|
|
13
|
+
* its source, the SVG or a PNG, lists the findings, and names a box under the
|
|
14
|
+
* pointer. It needs nothing running and fetches nothing, so it can be
|
|
15
|
+
* attached, mailed or committed. `file` is the source file's name, such as
|
|
16
|
+
* `flow.reladraw`; without its extension it is the title when the file states
|
|
17
|
+
* none, and the name everything saved from the page is given. The page is a
|
|
18
|
+
* snapshot of the file at `at`, and says so under its title.
|
|
15
19
|
*/
|
|
16
|
-
export function drawPage(source,
|
|
20
|
+
export function drawPage(source, file, options = {}, at = new Date()) {
|
|
21
|
+
const name = file.replace(/\.[^.]+$/, '') || file;
|
|
22
|
+
const drawn = `drawn from ${file}, ${stamp(at)}`;
|
|
17
23
|
const run = (text) => {
|
|
18
24
|
const layout = resolve(parse(text), options);
|
|
19
25
|
const { svg, tagged } = renderTagged(layout, options);
|
|
20
26
|
const found = options.checks === false ? [] : findings(layout, options.checks ? options.checks(fileChecks(layout)) : fileChecks(layout));
|
|
21
27
|
const title = layout.diagram['title'] ?? name;
|
|
22
|
-
|
|
28
|
+
const theme = themeOf(layout, options);
|
|
29
|
+
return {
|
|
30
|
+
html: page({ title, name, drawn, source, svg, tagged, findings: found, theme, graph: graphOf(layout, theme) }),
|
|
31
|
+
findings: found,
|
|
32
|
+
};
|
|
23
33
|
};
|
|
24
34
|
try {
|
|
25
35
|
return run(source);
|
|
@@ -28,7 +38,53 @@ export function drawPage(source, name, options = {}) {
|
|
|
28
38
|
throw error instanceof SourceError ? blame(source, error, run) : error;
|
|
29
39
|
}
|
|
30
40
|
}
|
|
31
|
-
|
|
41
|
+
const BADGE_SIDE = 20;
|
|
42
|
+
function graphOf(layout, theme) {
|
|
43
|
+
const views = (links) => (links ?? []).map((link) => {
|
|
44
|
+
const label = linkLabel(link, links);
|
|
45
|
+
const icon = link.icon === undefined
|
|
46
|
+
? { letters: [...label.trim()][0]?.toUpperCase() ?? '?' }
|
|
47
|
+
: 'picture' in link.icon
|
|
48
|
+
? layout.linkIcons[link.icon.picture]
|
|
49
|
+
: link.icon;
|
|
50
|
+
return { href: link.href, label, badge: iconSvg(icon, BADGE_SIDE, theme) };
|
|
51
|
+
});
|
|
52
|
+
// A node with no links of its own has its container's, as a click on it in
|
|
53
|
+
// the SVG follows its container's.
|
|
54
|
+
const linksFor = (node) => {
|
|
55
|
+
for (let at = node; at; at = at.parent) {
|
|
56
|
+
const own = linksOf(at.attrs);
|
|
57
|
+
if (own !== undefined)
|
|
58
|
+
return own;
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
};
|
|
62
|
+
const nodes = {};
|
|
63
|
+
for (const node of layout.nodes) {
|
|
64
|
+
const text = plainText(node.text);
|
|
65
|
+
nodes[node.name] = { label: text === '' ? node.name.split('.').pop() : text, links: views(linksFor(node)) };
|
|
66
|
+
}
|
|
67
|
+
const edges = layout.edges.map((edge) => {
|
|
68
|
+
// The upstream end is the one a line runs from: the end without a mark
|
|
69
|
+
// when only the other has one, and otherwise the end written first.
|
|
70
|
+
const [, start = '', , end = ''] = edge.arrow.match(/^(.*?)(-{1,2})(.*)$/) ?? [];
|
|
71
|
+
const backwards = start !== '' && end === '';
|
|
72
|
+
const [up, down] = backwards ? [edge.to, edge.from] : [edge.from, edge.to];
|
|
73
|
+
const text = edge.texts.map((one) => plainText(one.text)).filter((one) => one !== '').join(' · ');
|
|
74
|
+
return { up: up.name, down: down.name, label: text, links: views(linksOf(edge.attrs)) };
|
|
75
|
+
});
|
|
76
|
+
return { nodes, edges };
|
|
77
|
+
}
|
|
78
|
+
/** A text as a reader sees it: markup such as `[dim]` taken out, its lines run together. */
|
|
79
|
+
function plainText(text) {
|
|
80
|
+
return text.replace(/\[\/?[a-z][a-z0-9-]*\]/gi, '').replace(/\s*\n\s*/g, ' ').trim();
|
|
81
|
+
}
|
|
82
|
+
/** A local time as `2026-11-05 14:32`, which sorts and reads the same everywhere. */
|
|
83
|
+
function stamp(at) {
|
|
84
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
85
|
+
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
|
86
|
+
}
|
|
87
|
+
function page({ title, name, drawn, source, svg, tagged, findings, theme, graph }) {
|
|
32
88
|
const hex = (color) => /^#[0-9a-f]{6}$/i.test(color);
|
|
33
89
|
// The browser's own controls (scroll bars, focus rings) follow the page, so
|
|
34
90
|
// a dark diagram doesn't get a light one's. A color written by hand in a
|
|
@@ -37,7 +93,9 @@ function page({ title, name, svg, tagged, findings, theme }) {
|
|
|
37
93
|
const data = {
|
|
38
94
|
svg,
|
|
39
95
|
name,
|
|
96
|
+
source,
|
|
40
97
|
findings: findings.map((finding) => ({ kind: finding.kind, message: finding.message, line: finding.line })),
|
|
98
|
+
graph,
|
|
41
99
|
};
|
|
42
100
|
// Replaced by functions, so a `$` in a title or a drawing is taken as written.
|
|
43
101
|
// The zoom's code goes in first, so nothing a diagram says can be taken for its marker.
|
|
@@ -45,6 +103,7 @@ function page({ title, name, svg, tagged, findings, theme }) {
|
|
|
45
103
|
.replace('{{scheme}}', () => scheme)
|
|
46
104
|
.replace('{{title}}', () => escapeHtml(title))
|
|
47
105
|
.replace('{{heading}}', () => escapeHtml(title))
|
|
106
|
+
.replace('{{drawn}}', () => escapeHtml(drawn))
|
|
48
107
|
.replace('{{colors}}', () => [
|
|
49
108
|
`--page: ${theme.background};`,
|
|
50
109
|
`--ink: ${theme.text};`,
|
|
@@ -52,12 +111,14 @@ function page({ title, name, svg, tagged, findings, theme }) {
|
|
|
52
111
|
`--rule: ${theme.boxStroke};`,
|
|
53
112
|
`--accent: ${theme.primary.color};`,
|
|
54
113
|
`--on-accent: ${theme.primary.on};`,
|
|
114
|
+
'--sel: #e0b25a; --up: #22b98c; --down: #e5484d;',
|
|
55
115
|
].join(' '))
|
|
56
116
|
.replace('{{drawing}}', () => tagged)
|
|
57
117
|
.replace('{{data}}', () => scriptJson(data));
|
|
58
118
|
}
|
|
59
119
|
function escapeHtml(text) {
|
|
60
|
-
|
|
120
|
+
// `{` too, so a title or file name can't be taken for a later marker.
|
|
121
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/\{/g, '{');
|
|
61
122
|
}
|
|
62
123
|
/** JSON that can sit inside a `<script>` without ending it. */
|
|
63
124
|
function scriptJson(value) {
|
|
@@ -86,7 +147,10 @@ const PAGE = `<!doctype html>
|
|
|
86
147
|
padding: 10px 16px;
|
|
87
148
|
border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent);
|
|
88
149
|
}
|
|
89
|
-
|
|
150
|
+
.heading { flex: 1 1 16rem; min-width: 0; }
|
|
151
|
+
h1 { margin: 0; font-size: 16px; font-weight: 600; overflow-wrap: anywhere; }
|
|
152
|
+
.drawn { margin: 0; font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
153
|
+
.menu-list hr { width: 100%; margin: 2px 0; border: 0; border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
90
154
|
.tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
91
155
|
.tools .gap { width: 8px; }
|
|
92
156
|
button {
|
|
@@ -120,11 +184,57 @@ const PAGE = `<!doctype html>
|
|
|
120
184
|
padding: 2px 8px; border-radius: 4px; font-size: 12px; white-space: nowrap;
|
|
121
185
|
background: var(--accent); color: var(--on-accent);
|
|
122
186
|
}
|
|
123
|
-
|
|
124
|
-
width: min(
|
|
187
|
+
#side {
|
|
188
|
+
width: min(24rem, 36vw); display: flex; flex-direction: column; min-height: 0;
|
|
125
189
|
border-left: 1px solid color-mix(in srgb, var(--rule) 45%, transparent);
|
|
126
190
|
}
|
|
191
|
+
aside { display: flex; flex-direction: column; min-height: 0; flex: 1;
|
|
192
|
+
border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
127
193
|
aside[hidden] { display: none; }
|
|
194
|
+
|
|
195
|
+
/* ---- what is selected ---- */
|
|
196
|
+
#selected { padding: 10px 16px 12px; overflow: auto; flex: none; max-height: 60%; }
|
|
197
|
+
#selected .empty { margin: 0; color: var(--muted); font-size: 13px; }
|
|
198
|
+
#selected h2 { margin: 0 0 8px; font-size: 15px; font-weight: 600; color: var(--sel); overflow-wrap: anywhere; }
|
|
199
|
+
#selected h3 {
|
|
200
|
+
display: flex; align-items: center; gap: 8px; margin: 12px 0 4px;
|
|
201
|
+
font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--muted);
|
|
202
|
+
}
|
|
203
|
+
#selected h3 svg { flex: none; }
|
|
204
|
+
#selected .none { color: var(--muted); font-size: 13px; }
|
|
205
|
+
#selected .link {
|
|
206
|
+
display: flex; align-items: center; gap: 9px; padding: 4px 6px; margin: 0 -6px;
|
|
207
|
+
border-radius: 5px; color: var(--ink); text-decoration: none;
|
|
208
|
+
}
|
|
209
|
+
#selected .link:hover { background: color-mix(in srgb, var(--ink) 10%, transparent); }
|
|
210
|
+
#selected .link:hover .label { text-decoration: underline; }
|
|
211
|
+
#selected .link svg { flex: none; }
|
|
212
|
+
#selected .ends button { border-color: transparent; padding: 1px 4px; margin: 0 2px 0 -4px; font-size: 14px; }
|
|
213
|
+
#selected .ends button:hover { text-decoration: underline; }
|
|
214
|
+
#selected .all { margin-top: 12px; font-size: 12px; }
|
|
215
|
+
|
|
216
|
+
/* A click selects a box or a line. It stays amber; what feeds it is green and
|
|
217
|
+
what it feeds is red, the lines dashed and running the way they go; the
|
|
218
|
+
rest dims. Fixed colors rather than the theme's, so they mean the same in
|
|
219
|
+
every theme, and chosen to stay apart for the commonest color blindness. */
|
|
220
|
+
#view g[data-node], #view g[data-edge] { cursor: pointer; }
|
|
221
|
+
#view g.has-links:hover text { text-decoration: underline; }
|
|
222
|
+
#view svg.selecting g[data-name]:not(.sel):not(.up):not(.down) { opacity: .3; }
|
|
223
|
+
#view g[data-node].sel { filter: drop-shadow(0 0 2px var(--sel)) drop-shadow(0 0 6px var(--sel)); }
|
|
224
|
+
#view g[data-node].up { filter: drop-shadow(0 0 2px var(--up)) drop-shadow(0 0 4px color-mix(in srgb, var(--up) 60%, transparent)); }
|
|
225
|
+
#view g[data-node].down { filter: drop-shadow(0 0 2px var(--down)) drop-shadow(0 0 4px color-mix(in srgb, var(--down) 60%, transparent)); }
|
|
226
|
+
#view g[data-node].sel > :is(rect, path, circle, ellipse, polygon)[stroke]:not([stroke="none"]),
|
|
227
|
+
#view g[data-node].sel > a > :is(rect, path, circle, ellipse, polygon)[stroke]:not([stroke="none"]) { stroke: var(--sel); stroke-width: 2.5; }
|
|
228
|
+
#view g[data-node].up > :is(rect, path, circle, ellipse, polygon)[stroke]:not([stroke="none"]),
|
|
229
|
+
#view g[data-node].up > a > :is(rect, path, circle, ellipse, polygon)[stroke]:not([stroke="none"]) { stroke: var(--up); stroke-width: 2; }
|
|
230
|
+
#view g[data-node].down > :is(rect, path, circle, ellipse, polygon)[stroke]:not([stroke="none"]),
|
|
231
|
+
#view g[data-node].down > a > :is(rect, path, circle, ellipse, polygon)[stroke]:not([stroke="none"]) { stroke: var(--down); stroke-width: 2; }
|
|
232
|
+
#view g[data-edge].sel :is(line, path[fill="none"], polyline):not(.target) { stroke: var(--sel); stroke-width: 2; }
|
|
233
|
+
#view g[data-edge].up :is(line, path[fill="none"], polyline):not(.target) { stroke: var(--up); stroke-dasharray: 8 5; animation: run .9s linear infinite; }
|
|
234
|
+
#view g[data-edge].down :is(line, path[fill="none"], polyline):not(.target) { stroke: var(--down); stroke-dasharray: 8 5; animation: run .9s linear infinite; }
|
|
235
|
+
#view g[data-edge] .target { stroke: #000 !important; stroke-opacity: 0; }
|
|
236
|
+
@keyframes run { to { stroke-dashoffset: -26; } }
|
|
237
|
+
@media (prefers-reduced-motion: reduce) { #view g[data-edge] :is(line, path, polyline) { animation: none !important; } }
|
|
128
238
|
aside h2 { margin: 0; padding: 10px 16px 6px; font-size: 13px; font-weight: 600; }
|
|
129
239
|
aside h2 .what { display: block; font-weight: 400; font-size: 12px; color: var(--muted); }
|
|
130
240
|
aside ol { list-style: none; margin: 0; padding: 0 8px 12px; overflow: auto; }
|
|
@@ -138,13 +248,16 @@ const PAGE = `<!doctype html>
|
|
|
138
248
|
.note { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
|
|
139
249
|
@media (max-width: 700px) {
|
|
140
250
|
main { flex-direction: column; }
|
|
141
|
-
|
|
251
|
+
#side { width: auto; max-height: 45%; border-left: 0; border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
142
252
|
}
|
|
143
253
|
</style>
|
|
144
254
|
</head>
|
|
145
255
|
<body>
|
|
146
256
|
<header>
|
|
147
|
-
<
|
|
257
|
+
<div class="heading">
|
|
258
|
+
<h1>{{heading}}</h1>
|
|
259
|
+
<p class="drawn">{{drawn}}</p>
|
|
260
|
+
</div>
|
|
148
261
|
<div class="tools">
|
|
149
262
|
<button id="out" type="button" title="Zoom out (-)" aria-label="Zoom out">−</button>
|
|
150
263
|
<span id="scale" aria-live="polite">100%</span>
|
|
@@ -154,11 +267,14 @@ const PAGE = `<!doctype html>
|
|
|
154
267
|
<span class="gap"></span>
|
|
155
268
|
<button id="full" type="button" title="Full screen (f)">Full screen</button>
|
|
156
269
|
<span class="menu">
|
|
157
|
-
<button id="
|
|
158
|
-
<span id="
|
|
159
|
-
<button id="
|
|
160
|
-
<button id="save-
|
|
161
|
-
<
|
|
270
|
+
<button id="save" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="save-list">Save ▾</button>
|
|
271
|
+
<span id="save-list" class="menu-list" hidden>
|
|
272
|
+
<button id="save-page" type="button" title="Save this page as one HTML file, to keep or send">This page</button>
|
|
273
|
+
<button id="save-source" type="button" title="Save the .reladraw file this page was drawn from">.reladraw source</button>
|
|
274
|
+
<hr>
|
|
275
|
+
<button id="save-svg" type="button" title="Save the picture as an SVG file">SVG</button>
|
|
276
|
+
<button id="save-png" type="button" title="Save the picture as a PNG, at twice its size">PNG</button>
|
|
277
|
+
<button id="copy-svg" type="button" title="Copy the picture's SVG to the clipboard">Copy SVG</button>
|
|
162
278
|
</span>
|
|
163
279
|
</span>
|
|
164
280
|
<button id="toggle-findings" type="button" aria-controls="findings"></button>
|
|
@@ -171,13 +287,21 @@ const PAGE = `<!doctype html>
|
|
|
171
287
|
</div>
|
|
172
288
|
<div id="tip"></div>
|
|
173
289
|
</div>
|
|
174
|
-
<
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
290
|
+
<div id="side">
|
|
291
|
+
<section id="selected" aria-label="Selected" aria-live="polite">
|
|
292
|
+
<p class="empty">Click a box or a line to see its links and what it connects to.</p>
|
|
293
|
+
</section>
|
|
294
|
+
<aside id="findings" aria-label="Findings">
|
|
295
|
+
<h2>Findings <span class="what">where the picture, drawn as written, may not read as meant</span></h2>
|
|
296
|
+
<ol id="finding-list"></ol>
|
|
297
|
+
</aside>
|
|
298
|
+
</div>
|
|
178
299
|
</main>
|
|
179
300
|
<script>
|
|
180
301
|
(function () {
|
|
302
|
+
// The page as written, taken before anything below changes it, so This
|
|
303
|
+
// page saves what reladraw wrote rather than the current zoom and highlights.
|
|
304
|
+
var written = '<!doctype html>\\n' + document.documentElement.outerHTML + '\\n';
|
|
181
305
|
var DATA = {{data}};
|
|
182
306
|
var view = document.getElementById('view');
|
|
183
307
|
var stage = document.getElementById('stage');
|
|
@@ -225,20 +349,68 @@ const PAGE = `<!doctype html>
|
|
|
225
349
|
setTimeout(function () { button.textContent = was; }, 1200);
|
|
226
350
|
}
|
|
227
351
|
|
|
228
|
-
//
|
|
229
|
-
// playground
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
function
|
|
234
|
-
|
|
235
|
-
|
|
352
|
+
// Chrome and Edge ask where to save and under what name, as the
|
|
353
|
+
// playground's Save… does; elsewhere the file goes where the browser puts
|
|
354
|
+
// downloads. The dialog opens before the file is made, because it must
|
|
355
|
+
// open while the click still counts and a PNG takes a moment to draw.
|
|
356
|
+
// \`make\` gives a promise of the blob.
|
|
357
|
+
function saveFile(button, name, description, type, make) {
|
|
358
|
+
function download() {
|
|
359
|
+
make().then(function (blob) { saveBlob(blob, name); }, function () { flash(button, 'Failed'); });
|
|
360
|
+
}
|
|
361
|
+
if (!window.showSaveFilePicker) return download();
|
|
362
|
+
var accept = {};
|
|
363
|
+
accept[type] = [name.slice(name.lastIndexOf('.'))];
|
|
364
|
+
// A page can't start the dialog in its own folder; the \`id\` makes it
|
|
365
|
+
// start in the folder last saved to from any reladraw page instead.
|
|
366
|
+
window.showSaveFilePicker({ id: 'reladraw-page', suggestedName: name, types: [{ description: description, accept: accept }] }).then(
|
|
367
|
+
function (handle) {
|
|
368
|
+
return make().then(function (blob) {
|
|
369
|
+
return handle.createWritable().then(function (out) {
|
|
370
|
+
return out.write(blob).then(function () { return out.close(); });
|
|
371
|
+
});
|
|
372
|
+
}).then(function () { flash(button, 'Saved'); }, function () { flash(button, 'Failed'); });
|
|
373
|
+
},
|
|
374
|
+
function (e) {
|
|
375
|
+
if (e && e.name === 'AbortError') return;
|
|
376
|
+
// Refused outright, as inside a cross-origin frame.
|
|
377
|
+
download();
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function ready(blob) {
|
|
383
|
+
return Promise.resolve(blob);
|
|
236
384
|
}
|
|
237
|
-
|
|
385
|
+
|
|
386
|
+
// Every way out of the page is under one Save button: the page, its
|
|
387
|
+
// source, and the picture. The list stays open after a choice, so "Copied"
|
|
388
|
+
// can be read, and closes on a click elsewhere or Escape.
|
|
389
|
+
var saveButton = document.getElementById('save');
|
|
390
|
+
var saveList = document.getElementById('save-list');
|
|
391
|
+
function showSave(open) {
|
|
392
|
+
saveList.hidden = !open;
|
|
393
|
+
saveButton.setAttribute('aria-expanded', String(open));
|
|
394
|
+
}
|
|
395
|
+
saveButton.onclick = function () { showSave(saveList.hidden); };
|
|
238
396
|
document.addEventListener('click', function (event) {
|
|
239
|
-
if (!
|
|
397
|
+
if (!saveList.hidden && !event.target.closest('.menu')) showSave(false);
|
|
240
398
|
});
|
|
241
399
|
|
|
400
|
+
var savePage = document.getElementById('save-page');
|
|
401
|
+
savePage.onclick = function () {
|
|
402
|
+
saveFile(savePage, DATA.name + '.html', 'Web page', 'text/html', function () {
|
|
403
|
+
return ready(new Blob([written], { type: 'text/html' }));
|
|
404
|
+
});
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
var saveSource = document.getElementById('save-source');
|
|
408
|
+
saveSource.onclick = function () {
|
|
409
|
+
saveFile(saveSource, DATA.name + '.reladraw', 'reladraw source', 'text/plain', function () {
|
|
410
|
+
return ready(new Blob([DATA.source], { type: 'text/plain' }));
|
|
411
|
+
});
|
|
412
|
+
};
|
|
413
|
+
|
|
242
414
|
// The clipboard API needs a secure context; a page opened from disk may
|
|
243
415
|
// not count as one, so the old selection route is the fallback.
|
|
244
416
|
var copySvg = document.getElementById('copy-svg');
|
|
@@ -258,8 +430,11 @@ const PAGE = `<!doctype html>
|
|
|
258
430
|
} else fallback();
|
|
259
431
|
};
|
|
260
432
|
|
|
261
|
-
document.getElementById('save-svg')
|
|
262
|
-
|
|
433
|
+
var saveSvg = document.getElementById('save-svg');
|
|
434
|
+
saveSvg.onclick = function () {
|
|
435
|
+
saveFile(saveSvg, DATA.name + '.svg', 'SVG image', 'image/svg+xml', function () {
|
|
436
|
+
return ready(new Blob([DATA.svg], { type: 'image/svg+xml' }));
|
|
437
|
+
});
|
|
263
438
|
};
|
|
264
439
|
|
|
265
440
|
// The browser draws the SVG onto a canvas with the fonts the page shows it
|
|
@@ -267,20 +442,23 @@ const PAGE = `<!doctype html>
|
|
|
267
442
|
// rather than a blob one, which some browsers count as foreign and refuse
|
|
268
443
|
// to let the canvas be read back.
|
|
269
444
|
var savePng = document.getElementById('save-png');
|
|
445
|
+
function drawPng() {
|
|
446
|
+
return new Promise(function (done, fail) {
|
|
447
|
+
var image = new Image();
|
|
448
|
+
image.onload = function () {
|
|
449
|
+
var box = svg.viewBox.baseVal;
|
|
450
|
+
var canvas = document.createElement('canvas');
|
|
451
|
+
canvas.width = box.width * 2;
|
|
452
|
+
canvas.height = box.height * 2;
|
|
453
|
+
canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
454
|
+
canvas.toBlob(function (blob) { blob ? done(blob) : fail(); }, 'image/png');
|
|
455
|
+
};
|
|
456
|
+
image.onerror = fail;
|
|
457
|
+
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(DATA.svg);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
270
460
|
savePng.onclick = function () {
|
|
271
|
-
|
|
272
|
-
image.onload = function () {
|
|
273
|
-
var box = svg.viewBox.baseVal;
|
|
274
|
-
var canvas = document.createElement('canvas');
|
|
275
|
-
canvas.width = box.width * 2;
|
|
276
|
-
canvas.height = box.height * 2;
|
|
277
|
-
canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
278
|
-
canvas.toBlob(function (blob) {
|
|
279
|
-
blob ? saveBlob(blob, DATA.name + '.png') : flash(savePng, 'Failed');
|
|
280
|
-
}, 'image/png');
|
|
281
|
-
};
|
|
282
|
-
image.onerror = function () { flash(savePng, 'Failed'); };
|
|
283
|
-
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(DATA.svg);
|
|
461
|
+
saveFile(savePng, DATA.name + '.png', 'PNG image', 'image/png', drawPng);
|
|
284
462
|
};
|
|
285
463
|
|
|
286
464
|
/* ---- a thing's name under the pointer ---- */
|
|
@@ -393,13 +571,197 @@ const PAGE = `<!doctype html>
|
|
|
393
571
|
list.append(item);
|
|
394
572
|
});
|
|
395
573
|
|
|
574
|
+
/* ---- selecting a box or a line ---- */
|
|
575
|
+
|
|
576
|
+
// A click selects and never follows a link: the links are in the panel,
|
|
577
|
+
// and a link is followed only from there. One line out lights by default;
|
|
578
|
+
// the panel's button lights everything upstream and downstream.
|
|
579
|
+
var graph = DATA.graph;
|
|
580
|
+
var selectedPanel = document.getElementById('selected');
|
|
581
|
+
var selection = null;
|
|
582
|
+
var everything = false;
|
|
583
|
+
|
|
584
|
+
function each(list, fn) { Array.prototype.forEach.call(list, fn); }
|
|
585
|
+
function nodeGroups(name) { return svg.querySelectorAll('g[data-node="' + CSS.escape(name) + '"]'); }
|
|
586
|
+
function edgeGroups(index) { return svg.querySelectorAll('g[data-edge="' + index + '"]'); }
|
|
587
|
+
|
|
588
|
+
// A line is too thin to hit, so each gets an unseen wider copy along it.
|
|
589
|
+
each(svg.querySelectorAll('g[data-edge]'), function (group) {
|
|
590
|
+
each(group.querySelectorAll('line, path[fill="none"], polyline'), function (stroke) {
|
|
591
|
+
if (stroke.getAttribute('stroke-opacity') === '0') { stroke.classList.add('target'); return; }
|
|
592
|
+
var wide = stroke.cloneNode(false);
|
|
593
|
+
['marker-start', 'marker-end', 'mask', 'stroke-dasharray'].forEach(function (name) { wide.removeAttribute(name); });
|
|
594
|
+
wide.setAttribute('stroke-width', '12');
|
|
595
|
+
wide.setAttribute('pointer-events', 'stroke');
|
|
596
|
+
wide.classList.add('target');
|
|
597
|
+
stroke.parentNode.insertBefore(wide, stroke.nextSibling);
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
Object.keys(graph.nodes).forEach(function (name) {
|
|
601
|
+
if (graph.nodes[name].links.length > 0) each(nodeGroups(name), function (group) { group.classList.add('has-links'); });
|
|
602
|
+
});
|
|
603
|
+
graph.edges.forEach(function (edge, index) {
|
|
604
|
+
if (edge.links.length > 0) each(edgeGroups(index), function (group) { group.classList.add('has-links'); });
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
function clearMarks() {
|
|
608
|
+
svg.classList.remove('selecting');
|
|
609
|
+
each(svg.querySelectorAll('.sel, .up, .down'), function (group) { group.classList.remove('sel', 'up', 'down'); });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// What lights: the edges into and out of a node, and the nodes at their far
|
|
613
|
+
// ends; with \`everything\`, on through every line upstream and downstream.
|
|
614
|
+
function lightFrom(startUp, startDown) {
|
|
615
|
+
var up = {}, down = {}, upEdges = {}, downEdges = {};
|
|
616
|
+
function walk(names, seen, edges, toward) {
|
|
617
|
+
var queue = names.slice();
|
|
618
|
+
while (queue.length) {
|
|
619
|
+
var at = queue.shift();
|
|
620
|
+
graph.edges.forEach(function (edge, index) {
|
|
621
|
+
var near = toward === 'up' ? edge.down : edge.up;
|
|
622
|
+
var far = toward === 'up' ? edge.up : edge.down;
|
|
623
|
+
if (near !== at || edges[index]) return;
|
|
624
|
+
edges[index] = true;
|
|
625
|
+
if (!seen[far]) { seen[far] = true; if (everything) queue.push(far); }
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
walk(startUp, up, upEdges, 'up');
|
|
630
|
+
walk(startDown, down, downEdges, 'down');
|
|
631
|
+
return { up: up, down: down, upEdges: upEdges, downEdges: downEdges };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function select(next) {
|
|
635
|
+
selection = next;
|
|
636
|
+
clearMarks();
|
|
637
|
+
if (!next) { showSelected(); return; }
|
|
638
|
+
svg.classList.add('selecting');
|
|
639
|
+
var lit;
|
|
640
|
+
if (next.kind === 'node') {
|
|
641
|
+
each(nodeGroups(next.key), function (group) { group.classList.add('sel'); });
|
|
642
|
+
lit = lightFrom([next.key], [next.key]);
|
|
643
|
+
} else {
|
|
644
|
+
var edge = graph.edges[next.key];
|
|
645
|
+
each(edgeGroups(next.key), function (group) { group.classList.add('sel'); });
|
|
646
|
+
// One line out from a line is its two ends; everything goes on from them.
|
|
647
|
+
lit = everything ? lightFrom([edge.up], [edge.down]) : { up: {}, down: {}, upEdges: {}, downEdges: {} };
|
|
648
|
+
lit.up[edge.up] = true;
|
|
649
|
+
lit.down[edge.down] = true;
|
|
650
|
+
delete lit.upEdges[next.key];
|
|
651
|
+
delete lit.downEdges[next.key];
|
|
652
|
+
}
|
|
653
|
+
Object.keys(lit.upEdges).forEach(function (index) { each(edgeGroups(index), function (group) { group.classList.add('up'); }); });
|
|
654
|
+
Object.keys(lit.downEdges).forEach(function (index) { each(edgeGroups(index), function (group) { group.classList.add('down'); }); });
|
|
655
|
+
Object.keys(lit.up).forEach(function (name) { if (!(next.kind === 'node' && name === next.key)) each(nodeGroups(name), function (group) { group.classList.add('up'); }); });
|
|
656
|
+
Object.keys(lit.down).forEach(function (name) {
|
|
657
|
+
if (next.kind === 'node' && name === next.key) return;
|
|
658
|
+
each(nodeGroups(name), function (group) { if (!group.classList.contains('up')) group.classList.add('down'); });
|
|
659
|
+
});
|
|
660
|
+
showSelected();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function sample(color) {
|
|
664
|
+
return '<svg width="26" height="8" aria-hidden="true"><line x1="1" y1="4" x2="25" y2="4" stroke="' + color + '" stroke-width="2.4" stroke-dasharray="7 4"/></svg>';
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function element(tag, className, text) {
|
|
668
|
+
var made = document.createElement(tag);
|
|
669
|
+
if (className) made.className = className;
|
|
670
|
+
if (text !== undefined) made.textContent = text;
|
|
671
|
+
return made;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function endsList(names) {
|
|
675
|
+
var holder = element('div', 'ends');
|
|
676
|
+
if (names.length === 0) { holder.append(element('span', 'none', 'nothing')); return holder; }
|
|
677
|
+
names.forEach(function (name) {
|
|
678
|
+
var button = element('button', '', graph.nodes[name] ? graph.nodes[name].label : name);
|
|
679
|
+
button.type = 'button';
|
|
680
|
+
button.onclick = function () { select({ kind: 'node', key: name }); };
|
|
681
|
+
holder.append(button);
|
|
682
|
+
});
|
|
683
|
+
return holder;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function showSelected() {
|
|
687
|
+
selectedPanel.textContent = '';
|
|
688
|
+
if (!selection) {
|
|
689
|
+
selectedPanel.append(element('p', 'empty', 'Click a box or a line to see its links and what it connects to.'));
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
var heading, links, from, to;
|
|
693
|
+
if (selection.kind === 'node') {
|
|
694
|
+
var node = graph.nodes[selection.key];
|
|
695
|
+
heading = node ? node.label : selection.key;
|
|
696
|
+
links = node ? node.links : [];
|
|
697
|
+
from = []; to = [];
|
|
698
|
+
graph.edges.forEach(function (edge) {
|
|
699
|
+
if (edge.down === selection.key && from.indexOf(edge.up) < 0) from.push(edge.up);
|
|
700
|
+
if (edge.up === selection.key && to.indexOf(edge.down) < 0) to.push(edge.down);
|
|
701
|
+
});
|
|
702
|
+
} else {
|
|
703
|
+
var edge = graph.edges[selection.key];
|
|
704
|
+
var name = function (key) { return graph.nodes[key] ? graph.nodes[key].label : key; };
|
|
705
|
+
heading = edge.label || name(edge.up) + ' \\u2192 ' + name(edge.down);
|
|
706
|
+
links = edge.links;
|
|
707
|
+
from = [edge.up]; to = [edge.down];
|
|
708
|
+
}
|
|
709
|
+
selectedPanel.append(element('h2', '', heading));
|
|
710
|
+
selectedPanel.append(element('h3', '', 'Links'));
|
|
711
|
+
if (links.length === 0) selectedPanel.append(element('div', 'none', 'none'));
|
|
712
|
+
links.forEach(function (link) {
|
|
713
|
+
var row = element('a', 'link');
|
|
714
|
+
row.href = link.href;
|
|
715
|
+
row.target = '_blank';
|
|
716
|
+
row.rel = 'noopener';
|
|
717
|
+
row.title = link.href;
|
|
718
|
+
row.innerHTML = link.badge;
|
|
719
|
+
row.append(element('span', 'label', link.label));
|
|
720
|
+
selectedPanel.append(row);
|
|
721
|
+
});
|
|
722
|
+
var comes = element('h3', '', 'Comes from');
|
|
723
|
+
comes.insertAdjacentHTML('beforeend', sample('var(--up)'));
|
|
724
|
+
selectedPanel.append(comes, endsList(from));
|
|
725
|
+
var goes = element('h3', '', 'Goes to');
|
|
726
|
+
goes.insertAdjacentHTML('beforeend', sample('var(--down)'));
|
|
727
|
+
selectedPanel.append(goes, endsList(to));
|
|
728
|
+
var all = element('button', 'all', 'Light everything upstream and downstream');
|
|
729
|
+
all.type = 'button';
|
|
730
|
+
all.setAttribute('aria-pressed', String(everything));
|
|
731
|
+
all.onclick = function () { everything = !everything; select(selection); };
|
|
732
|
+
selectedPanel.append(all);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// The pan and zoom capture the pointer, so the click lands on the view; what
|
|
736
|
+
// was under the pointer is noted when it went down, and a drag selects nothing.
|
|
737
|
+
var pressed = null;
|
|
738
|
+
view.addEventListener('pointerdown', function (event) {
|
|
739
|
+
pressed = event.button === 0 ? { x: event.clientX, y: event.clientY, target: event.target } : null;
|
|
740
|
+
}, true);
|
|
741
|
+
view.addEventListener('pointerup', function (event) {
|
|
742
|
+
if (!pressed) return;
|
|
743
|
+
var moved = Math.hypot(event.clientX - pressed.x, event.clientY - pressed.y) > 4;
|
|
744
|
+
var target = pressed.target;
|
|
745
|
+
pressed = null;
|
|
746
|
+
if (moved) return;
|
|
747
|
+
var group = target.closest && target.closest('g[data-node], g[data-edge]');
|
|
748
|
+
if (!group) select(null);
|
|
749
|
+
else if (group.hasAttribute('data-node')) select({ kind: 'node', key: group.getAttribute('data-node') });
|
|
750
|
+
else select({ kind: 'edge', key: Number(group.getAttribute('data-edge')) });
|
|
751
|
+
});
|
|
752
|
+
// A link in the drawing is followed from the panel, not by clicking the drawing.
|
|
753
|
+
view.addEventListener('click', function (event) {
|
|
754
|
+
var link = event.target.closest && event.target.closest('a');
|
|
755
|
+
if (link && svg.contains(link)) event.preventDefault();
|
|
756
|
+
});
|
|
757
|
+
|
|
396
758
|
/* ---- keys, beyond the zoom's own ---- */
|
|
397
759
|
|
|
398
760
|
document.addEventListener('keydown', function (event) {
|
|
399
761
|
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
400
762
|
if (event.target.closest && event.target.closest('input, textarea')) return;
|
|
401
763
|
if (event.key === 'f' && !full.hidden) toggleFull();
|
|
402
|
-
else if (event.key === 'Escape') {
|
|
764
|
+
else if (event.key === 'Escape') { showSave(false); unlight(); select(null); }
|
|
403
765
|
else return;
|
|
404
766
|
event.preventDefault();
|
|
405
767
|
});
|