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/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 { renderTagged, themeOf } from './render.js';
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 the SVG
12
- * or a PNG, lists the findings, and names a box under the pointer. It needs
13
- * nothing running and fetches nothing, so it can be attached, mailed or
14
- * committed. `name` is the file's name, the title when the file states none.
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, name, options = {}) {
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
- return { html: page({ title, name, svg, tagged, findings: found, theme: themeOf(layout, options) }), findings: found };
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
- function page({ title, name, svg, tagged, findings, theme }) {
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
- return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
120
+ // `{` too, so a title or file name can't be taken for a later marker.
121
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/\{/g, '&#123;');
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
- h1 { flex: 1 1 16rem; margin: 0; font-size: 16px; font-weight: 600; overflow-wrap: anywhere; }
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
- aside {
124
- width: min(26rem, 38vw); display: flex; flex-direction: column; min-height: 0;
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
- aside { width: auto; max-height: 40%; border-left: 0; border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
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
- <h1>{{heading}}</h1>
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="export" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="export-list">Export ▾</button>
158
- <span id="export-list" class="menu-list" hidden>
159
- <button id="copy-svg" type="button">Copy SVG</button>
160
- <button id="save-svg" type="button" title="Save the picture as an SVG file">Save SVG</button>
161
- <button id="save-png" type="button" title="Save the picture as a PNG, at twice its size">Save PNG</button>
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
- <aside id="findings" aria-label="Findings">
175
- <h2>Findings <span class="what">where the picture, drawn as written, may not read as meant</span></h2>
176
- <ol id="finding-list"></ol>
177
- </aside>
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
- // Copy SVG, Save SVG and Save PNG are under one Export button, as in the
229
- // playground. The list stays open after a choice, so "Copied" can be read,
230
- // and closes on a click elsewhere or Escape.
231
- var exportButton = document.getElementById('export');
232
- var exportList = document.getElementById('export-list');
233
- function showExport(open) {
234
- exportList.hidden = !open;
235
- exportButton.setAttribute('aria-expanded', String(open));
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
- exportButton.onclick = function () { showExport(exportList.hidden); };
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 (!exportList.hidden && !event.target.closest('.menu')) showExport(false);
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').onclick = function () {
262
- saveBlob(new Blob([DATA.svg], { type: 'image/svg+xml' }), DATA.name + '.svg');
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
- var image = new Image();
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') { showExport(false); unlight(); }
764
+ else if (event.key === 'Escape') { showSave(false); unlight(); select(null); }
403
765
  else return;
404
766
  event.preventDefault();
405
767
  });