Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/graph.js

96.3 KiB, 1 run

created by r2519314175:1371, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* graph.js — the Diamonds and the links between them, drawn inside #panel-graph
2 * and EDITED there.
3 *
4 * This is a verification instrument that the user may also arrange. Its value
5 * is unchanged: the same store always draws the same picture, so a person can
6 * look at it to check that the association structure is the one they believe
7 * they built, and a picture that rearranged itself between two looks could not
8 * settle that question.
9 *
10 * What changed is where a coordinate may come from. Until now every coordinate
11 * was computed from the Diamonds and the links. Now a Diamond may also carry a
12 * STORED position, put there by a drag or by "organise", and a stored position
13 * wins. That is not a hole in the guarantee, it is the guarantee restated: the
14 * picture is a pure function of the store, and the store has one more thing in
15 * it. Auto-layout remains the rule for a Diamond with no stored position, so a
16 * new one still appears somewhere sensible rather than at the origin.
17 *
18 * THE ONE ANIMATION, and why the guarantee survives it. While a link is being
19 * drawn, an arrow follows the pointer; while a Diamond is being dragged, its
20 * box follows the pointer AND the lines touching it are re-routed as it goes.
21 * All of that is pointer feedback and none of it is stored: it lives only
22 * between a mousedown and the mouseup that ends it, the arrow is drawn in a
23 * layer of its own that no serialisation of the picture includes, the re-routed
24 * lines are the same arithmetic the next draw does from the store, and the
25 * moment the gesture finishes the picture is redrawn from the store as it
26 * always was. Lines that waited for the mouseup were the alternative, and they
27 * showed a picture that was wrong for as long as the gesture lasted. There is
28 * still no force-direction and no randomness anywhere below, and no measurement
29 * taken from the page is ever used to place anything — a layout that asked the
30 * browser how wide a word came out would draw differently at a different font
31 * size, and then two people comparing the same store would be comparing two
32 * pictures.
33 *
34 * THE VIEW IS NOT THE PICTURE. The canvas runs well past the ink, so a Diamond
35 * can be dragged out into empty space rather than up against an edge, and "All"
36 * scales the drawing down until every box is on screen. Both are properties of
37 * this window, like the scroll offset that pans it: the scale multiplies the
38 * whole picture uniformly and no coordinate is ever computed from it, so two
39 * people reading one store still see one arrangement, whatever size their two
40 * windows made of it.
41 *
42 * COLOURS. A Diamond may carry a colour chosen elsewhere in the app and kept in
43 * `daimond-tile-prefs`. This module reads that store and never writes it; a
44 * missing, empty or malformed entry means the theme's own colour, which is what
45 * every Diamond had before.
46 *
47 * What is drawn:
48 * - Every Diamond is a node, including one nothing points at. An unlinked
49 * Diamond is information — usually the information that a link you meant to
50 * draw was never drawn — so it goes in a band of its own at the foot rather
51 * than being left out.
52 * - Every link whose BOTH ends are Diamonds is an edge, arrowed from `from`
53 * to `to`, carrying its relations as chips stacked on the line — one chip
54 * per relation, the same pills the Diamonds wear. Two links between the
55 * same pair are two lines, and their chips sit at different points ALONG
56 * those lines, since a picture where one word covers another is not one
57 * anybody can check anything against.
58 * - A link to a file, a page or a chat is not a node. It is a count on the
59 * Diamond it touches, because a picture that grew a box for every artefact
60 * would stop showing the structure it exists to show.
61 * - A cycle is legal and is drawn: its closing edges are dashed and badged,
62 * and so are the Diamonds on it. Making one visible is the point; refusing
63 * to draw one would hide exactly the case worth seeing. Dragging does not
64 * suppress it and neither does organising — both redraw from the same
65 * classification.
66 *
67 * What can be done to it:
68 * - Drag a Diamond with either button to move it. Where it lands is written
69 * down. The right button was asked for and the left was kept: a press that
70 * does not travel still means what it always meant, a click on the left and
71 * the menu on the right.
72 * - Arm "Link", click a source, click a target: a link is asserted, and the
73 * arrow follows the pointer in between. Escape, or a click on empty space,
74 * leaves the mode.
75 * - Click a link to edit its relations and its note, or to delete it.
76 * - Middle-drag to pan. Where the view was left is written down too.
77 * - Right-click for a menu, which carries "organise".
78 * - "All" scales the picture to fit the window; the menu's "Reset the view"
79 * puts it back to full size at the origin.
80 */
81(function () {
82 'use strict';
83
84 // The wasm module, resolved against THIS script rather than the document, so
85 // the app still finds it when served from a sub-path.
86 var SELF = (document.currentScript && document.currentScript.src) || '';
87 var PKG = SELF ? new URL('../pkg/oxedyne_daimond.js', SELF).href
88 : '../pkg/oxedyne_daimond.js';
89
90 // ── Words ──────────────────────────────────────────────────
91
92 /// A string, from the catalogue and from nowhere else.
93 ///
94 /// This file used to carry a `STR` table of its own English -- the whole of
95 /// the graph editor's wording -- "held here until they reach `i18n/en.js`".
96 /// They have reached it, in every locale, so the table is gone: a second copy
97 /// of a sentence is a sentence that drifts, and this one was invisible to
98 /// `dev/i18nfallback.mjs` because it was a table rather than a call with a
99 /// fallback beside it. What English the graph shows is now `i18n/en.js`'s,
100 /// which is what a translator edits.
101 ///
102 /// The guard is the house form (see `terminal.js`): where the engine is not
103 /// on the page at all, the key itself is the answer. `DaimondI18n.t` already
104 /// falls back from a part-translated locale to English, so nothing else here
105 /// has to.
106 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
107
108 /// The plural form, delegated so a locale's own rule still applies.
109 function tn(k, n, v) {
110 return window.DaimondI18n ? DaimondI18n.tn(k, n, v) : k + (n === 1 ? '.one' : '.other');
111 }
112
113 var SVGNS = 'http://www.w3.org/2000/svg';
114
115 // ── The drawing's fixed measurements ───────────────────────
116 // Constants, not measurements taken from the page: a layout that asked the
117 // browser how wide a word came out would draw differently at a different
118 // font size or on a different machine, and then two people comparing the
119 // same store would be comparing two pictures.
120 var NODE_W = 176; // a Diamond box
121 var NODE_H = 44;
122 var H_GAP = 30; // between boxes on one layer
123 var V_GAP = 92; // between layers, which is where the labels live
124 var PAD = 28; // around the whole picture
125 var ISO_GAP = 64; // before the band of unlinked Diamonds
126 var BAND_H = 22; // that band's heading
127 var NAME_MAX = 20; // characters of a name a box holds
128 var SWEEPS = 4; // barycentre passes; more does not move anything
129 var NUDGE = 18; // between parallel lines running the same way
130 // How far along its own line a parallel label slides, per lane. The drop
131 // between two layers is V_GAP, and 0.26 of the curve is about a fifth of it --
132 // some twenty pixels, comfortably more than a line of type -- so two labels
133 // clear each other however long the relations are, which pushing them
134 // sideways by a fixed amount could never promise.
135 var LABEL_T = 0.26;
136 // And the whole spread the labels of one group may use, so that four or five
137 // parallel links stagger inside their line rather than off the end of it.
138 var LABEL_SPAN = 0.72;
139 // How far the pointer must travel before a press on a Diamond is a drag
140 // rather than a click. Below it the gesture still opens the Diamond, which is
141 // what a click on a node has always meant.
142 var DRAG_MIN = 4;
143 // How far the left and right points of a box stand out from its corners. The
144 // top and bottom edges keep their length; only the sides kink.
145 var KINK = 12;
146 // Where a line may meet a side, as fractions along it. Three points rather
147 // than one, so the lines leaving a box fan out towards what they go to
148 // instead of all crowding its middle.
149 var PORTS = [0.25, 0.5, 0.75];
150 // How far the canvas runs past the ink, on the right and below. The drawing
151 // used to end exactly where the last box did, which put a wall wherever the
152 // Diamonds happened to reach; this is what makes the space to drag INTO.
153 var ROOM = 900;
154 // What "All" may scale the picture to. It never enlarges: a store with two
155 // Diamonds in it blown up to fill a window would look like a different store
156 // from the same two Diamonds beside forty others.
157 var ZOOM_MIN = 0.12;
158 var ZOOM_MAX = 1;
159 // A relation chip on a line: its height, the space either side of the word,
160 // the gap between two stacked chips, and the size its type is pinned at.
161 var CHIP_H = 15;
162 var CHIP_PAD = 7;
163 var CHIP_GAP = 3;
164 var CHIP_FS = 11;
165 // Characters of relation a link holds in all, mirroring `MAX_REL_LEN` in
166 // `src/diamond_link.rs`. The store is the authority and truncates; knowing
167 // the number here is what lets the form refuse before anything is lost.
168 var REL_MAX = 32;
169
170 var bodyEl = null;
171 var app = null; // the wasm handle, built once
172 var drawing = false; // one draw at a time; the last request wins
173 var again = false;
174 var lastStore = null; // what the picture on screen was drawn from
175 var lastGeo = null; // and where that draw put every box
176
177 // ── Where a Diamond has been put ───────────────────────────
178
179 /// The layout store: which Diamond sits where, and where the view was left.
180 ///
181 /// localStorage rather than OPFS, and per account -- accounts.js namespaces
182 /// every `daimond-*` key, so one person's arrangement is never another's. The
183 /// alternative was a field in each Diamond's `meta.json`, which would have put
184 /// a coordinate inside the record a daimon reads and folds; where a box sits
185 /// on one person's screen is not part of what a Diamond IS, and a fold that
186 /// had to preserve it would be carrying furniture.
187 ///
188 /// Shape, and it is the shape asked of the sync parcel:
189 ///
190 /// { v: 1, pos: { "<diamondId>": { x, y, t } }, pan: { x, y }, zoom: 1 }
191 ///
192 /// `pan` and `zoom` are this window's, not this account's, and neither goes
193 /// into the sync parcel: see [snapshot].
194 ///
195 /// `t` is the wall-clock millisecond that position was last set, PER DIAMOND,
196 /// so two devices that moved two different Diamonds keep both moves. A single
197 /// stamp over the whole map would make the later device's whole arrangement
198 /// win, and losing an arrangement is exactly the kind of quiet data loss the
199 /// tag-loss incident was.
200 var LAYOUT_KEY = 'daimond-graph';
201 var layout = null;
202
203 function loadLayout() {
204 if (layout) return layout;
205 layout = { v: 1, pos: {}, pan: { x: 0, y: 0 }, zoom: 1 };
206 try {
207 var raw = localStorage.getItem(LAYOUT_KEY);
208 if (raw) {
209 var rec = JSON.parse(raw);
210 if (rec && typeof rec === 'object') {
211 layout.pos = sanePos(rec.pos);
212 layout.pan = sanePan(rec.pan);
213 layout.zoom = saneZoom(rec.zoom);
214 }
215 }
216 } catch (e) { /* blocked or corrupt: nothing has been moved */ }
217 return layout;
218 }
219
220 /// Keep only what is a position. A hand-edited or half-written record must
221 /// not be able to put a box at `NaN`, which SVG draws nowhere at all.
222 function sanePos(o) {
223 var out = {};
224 if (!o || typeof o !== 'object') return out;
225 Object.keys(o).forEach(function (id) {
226 var p = o[id];
227 if (!p || typeof p !== 'object') return;
228 var x = Number(p.x), y = Number(p.y), ts = Number(p.t);
229 if (!isFinite(x) || !isFinite(y)) return;
230 out[id] = { x: Math.round(x), y: Math.round(y), t: isFinite(ts) ? ts : 0 };
231 });
232 return out;
233 }
234
235 function sanePan(o) {
236 var x = o ? Number(o.x) : 0, y = o ? Number(o.y) : 0;
237 return { x: isFinite(x) ? Math.max(0, Math.round(x)) : 0,
238 y: isFinite(y) ? Math.max(0, Math.round(y)) : 0 };
239 }
240
241 /// The view scale, held to the range "All" can reach. A stored nought or a
242 /// stored nonsense would draw the picture at no size at all, which reads as
243 /// an empty pane rather than as a bad number.
244 function saneZoom(z) {
245 var n = Number(z);
246 if (!isFinite(n) || n <= 0) return 1;
247 return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, n));
248 }
249
250 function saveLayout() {
251 try { localStorage.setItem(LAYOUT_KEY, JSON.stringify(loadLayout())); }
252 catch (e) { /* private mode, or full: the picture still draws */ }
253 }
254
255 /// Where this Diamond has been put, or nothing.
256 function storedPos(id) {
257 var p = loadLayout().pos[id];
258 return p ? { x: p.x, y: p.y } : null;
259 }
260
261 /// Write where a Diamond sits. Negative coordinates are clamped away: the
262 /// canvas grows to the right and downwards, so a box at -40 would be off the
263 /// only edge that cannot be scrolled to.
264 function putPos(id, x, y, stamp) {
265 var l = loadLayout();
266 l.pos[id] = {
267 x: Math.max(0, Math.round(x)),
268 y: Math.max(0, Math.round(y)),
269 t: stamp || Date.now(),
270 };
271 }
272
273 /// Forget where a Diamond was put, so auto-layout has it again.
274 function dropPos(id) { delete loadLayout().pos[id]; }
275
276 /// Drop the positions of Diamonds that are no longer there.
277 ///
278 /// Run on every draw, which is the only moment the live set is known here. A
279 /// deleted Diamond leaves an entry nothing will ever read again, and left
280 /// alone the map would grow for the life of the account. Pruning is safe
281 /// against the sync: the other device still holding that Diamond still holds
282 /// its position, and the union puts it back — so this loses a coordinate only
283 /// when the Diamond it belongs to has gone everywhere.
284 function pruneLayout(liveIds) {
285 var live = {}, l = loadLayout(), gone = 0;
286 liveIds.forEach(function (id) { live[id] = 1; });
287 Object.keys(l.pos).forEach(function (id) {
288 if (!live[id]) { delete l.pos[id]; gone++; }
289 });
290 return gone;
291 }
292
293 /// The layout as the sync parcel should carry it.
294 ///
295 /// The positions only. The pan is deliberately NOT here: it is a scroll offset
296 /// into a picture whose size depends on this window, and adopting another
297 /// device's would move the view for no reason the user could see.
298 function snapshot() {
299 var l = loadLayout(), pos = {}, stamp = 0;
300 Object.keys(l.pos).sort().forEach(function (id) {
301 var p = l.pos[id];
302 pos[id] = { x: p.x, y: p.y, t: p.t || 0 };
303 if (p.t > stamp) stamp = p.t;
304 });
305 // Sorted, and only the three fields, so two collects with nothing between
306 // them serialise to the same bytes -- which is what the push-skip in
307 // sync.js rests on. A map serialised in enumeration order would push for
308 // ever.
309 return { v: 1, stamp: stamp, pos: pos };
310 }
311
312 /// Take a layout record from the sync, merged against what is held here.
313 /// Returns true when something local moved.
314 ///
315 /// Per Diamond, the later stamp wins; on an equal stamp what is here is kept,
316 /// so applying a parcel this device already agrees with changes nothing and
317 /// the next parcel is unchanged. A section that restamped itself on apply is
318 /// the loop that had a freshly paired phone always holding news.
319 function adopt(rec) {
320 var incoming = sanePos(rec && rec.pos);
321 var l = loadLayout(), moved = false;
322 Object.keys(incoming).forEach(function (id) {
323 var mine = l.pos[id], theirs = incoming[id];
324 if (mine && (mine.t || 0) >= (theirs.t || 0)) return;
325 if (mine && mine.x === theirs.x && mine.y === theirs.y) return;
326 l.pos[id] = theirs;
327 moved = true;
328 });
329 if (moved) { saveLayout(); refreshIfVisible(); }
330 return moved;
331 }
332
333 // ── The colours a Diamond was given ────────────────────────
334
335 /// Where the rest of the app keeps each tile's own look. READ ONLY here.
336 ///
337 /// The record is `{ "<diamondId>": { bg: "#RRGGBB", fg: "#RRGGBB" }, … }` and
338 /// every part of it is optional: another module writes it, a person may have
339 /// hand-edited it, and an account that has never chosen a colour has no
340 /// record at all. So it is read like anything else that came from outside --
341 /// what parses as a colour is used and everything else is the theme's.
342 var TILE_KEY = 'daimond-tile-prefs';
343 var colourRaw = null; // the record the picture on screen was painted from
344
345 /// A CSS hex colour, or nothing. Deliberately narrow: this string ends up in
346 /// a `fill`, and a value that is not a colour would either be ignored by the
347 /// renderer or, worse, be a colour nobody chose.
348 function hexOr(v) {
349 return (typeof v === 'string' && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(v.trim()))
350 ? v.trim() : null;
351 }
352
353 /// Every Diamond's chosen colours, as `{ id: { bg, fg } }`, read fresh.
354 ///
355 /// Once per draw rather than once per box, and never cached between draws:
356 /// the store is small, and a cache would need invalidating from a module
357 /// this one does not own.
358 function tileColours() {
359 var out = {};
360 try {
361 colourRaw = localStorage.getItem(TILE_KEY) || '';
362 var rec = JSON.parse(colourRaw || '{}');
363 if (!rec || typeof rec !== 'object') return out;
364 Object.keys(rec).forEach(function (id) {
365 var p = rec[id];
366 if (!p || typeof p !== 'object') return;
367 var bg = hexOr(p.bg), fg = hexOr(p.fg);
368 if (bg || fg) out[id] = { bg: bg, fg: fg };
369 });
370 } catch (e) { /* absent, blocked or corrupt: the theme's colours */ }
371 return out;
372 }
373
374 /// Draw again if, and only if, a Diamond's colours have moved since the last
375 /// draw.
376 ///
377 /// The signals that a colour may have changed are not this module's, and one
378 /// of them fires whenever any Diamond is opened. Comparing the record itself
379 /// is one string compare, and it means a pane full of boxes is not rebuilt
380 /// every time somebody clicks one in the rail.
381 function refreshColours() {
382 if (!visible() || !lastStore) return;
383 var raw = '';
384 try { raw = localStorage.getItem(TILE_KEY) || ''; }
385 catch (e) { return; }
386 if (raw === colourRaw) return;
387 redraw();
388 }
389
390 // ── The wasm handle ────────────────────────────────────────
391
392 /// The `DaimondApp` this pane reads through, built on first use.
393 ///
394 /// The link reads are pure OPFS operations and work on any instance, so the
395 /// provider fields are placeholders -- nothing here ever calls a model.
396 function reader() {
397 if (app) return Promise.resolve(app);
398 return import(PKG).then(function (mod) {
399 app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
400 return app;
401 });
402 }
403
404 // ── Small helpers ──────────────────────────────────────────
405
406 function el(name, cls) {
407 var n = document.createElementNS(SVGNS, name);
408 if (cls) n.setAttribute('class', cls);
409 return n;
410 }
411
412 function attrs(n, o) {
413 for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) n.setAttribute(k, String(o[k]));
414 return n;
415 }
416
417 /// A plain HTML element, since the furniture around the picture is not SVG.
418 function h(name, cls, text) {
419 var n = document.createElement(name);
420 if (cls) n.className = cls;
421 if (text != null) n.textContent = text;
422 return n;
423 }
424
425 /// A `<title>`, which is how SVG carries a tooltip.
426 function tip(n, text) {
427 if (!text) return n;
428 var ti = el('title');
429 ti.textContent = text;
430 n.insertBefore(ti, n.firstChild);
431 return n;
432 }
433
434 /// Cut a name to what a box holds, with an ellipsis to say it was cut. The
435 /// full name is always on the node's tooltip, so nothing is lost.
436 function clip(s) {
437 var str = String(s || '');
438 var chars = Array.from(str); // so a cut never lands inside a character
439 if (chars.length <= NAME_MAX) return str;
440 return chars.slice(0, NAME_MAX - 1).join('') + '…';
441 }
442
443 /// The Diamond id a `diamond:<id>` reference names, or nothing.
444 function diamondOf(ref) {
445 var s = String(ref || '');
446 return s.slice(0, 8) === 'diamond:' ? s.slice(8) : null;
447 }
448
449 // ── Relations ──────────────────────────────────────────────
450 // A link's `rel` is one string in the store and several relations on the
451 // screen. The comma is what separates them, chosen because it is the one
452 // character `normalise_rel` leaves alone -- it lowercases and collapses
453 // whitespace, so a relation cannot hold a comma by accident and the split is
454 // exact. A record written before this, holding "derives from", still reads
455 // back as the one relation it always was.
456
457 /// The relations a stored `rel` names, in the order they were written and
458 /// without repeats.
459 function relsOf(rel) {
460 var out = [], seen = {};
461 String(rel || '').split(',').forEach(function (part) {
462 var r = part.trim().replace(/\s+/g, ' ');
463 if (!r || seen[r]) return;
464 seen[r] = 1;
465 out.push(r);
466 });
467 return out;
468 }
469
470 /// The string those relations are stored as. No space after the comma: the
471 /// store allows thirty-two characters in all, and a space per relation is a
472 /// relation's worth of them.
473 function relsToStore(list) { return list.join(','); }
474
475 /// A typed relation as the store would keep it, or an empty string for one
476 /// that says nothing. The commas go because they are the separator.
477 function tidyRel(s) {
478 return String(s || '').replace(/,/g, ' ').trim().replace(/\s+/g, ' ').toLowerCase();
479 }
480
481 // ── Reading the store ──────────────────────────────────────
482
483 /// Everything the picture is drawn from, in a fixed order.
484 function load() {
485 return reader().then(function (a) {
486 return Promise.all([a.list_diamonds(), a.all_links()]);
487 }).then(function (raw) {
488 // A trashed Diamond is gone from the rail, and it has to be gone from
489 // the picture too: `classify` below counts a link whose far end is not
490 // in this list as DANGLING, which is exactly what a link into a deleted
491 // Diamond is. Leaving it in would draw a node for something the rail
492 // says does not exist, and offer to open it.
493 var diamonds = JSON.parse(raw[0] || '[]').filter(function (d) {
494 try { return !(window.DaimondTrash && DaimondTrash.has(d && d.id)); }
495 catch (e) { return true; }
496 });
497 var links = JSON.parse(raw[1] || '[]');
498 // The rail is ordered by when a Diamond was last worked on, which
499 // changes under the picture. Ordered by id it does not.
500 diamonds.sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; });
501 links.sort(function (a, b) {
502 if (a.owner !== b.owner) return a.owner < b.owner ? -1 : 1;
503 return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
504 });
505 return { diamonds: diamonds, links: links };
506 });
507 }
508
509 // ── The model behind the picture ───────────────────────────
510
511 /// Sort the store into what is drawn: Diamond-to-Diamond edges, artefact
512 /// counts, and the links that point at a Diamond which is no longer there.
513 function classify(store) {
514 var known = {};
515 store.diamonds.forEach(function (d) { known[d.id] = d; });
516
517 var edges = []; // both ends are Diamonds that exist
518 var artefact = {}; // diamond id -> how many links to a file, page or chat
519 var dangling = 0; // links naming a Diamond that has been deleted
520
521 store.links.forEach(function (l) {
522 var a = diamondOf(l.from), b = diamondOf(l.to);
523 var aOk = a !== null && known[a], bOk = b !== null && known[b];
524 if (a !== null && b !== null) {
525 if (aOk && bOk) edges.push({ id: l.id, owner: l.owner, from: a, to: b, rel: l.rel || '', note: l.note || '', by: l.by || '' });
526 else dangling++;
527 return;
528 }
529 // One end is an artefact. It counts against whichever end is a
530 // Diamond we hold; a link between two artefacts touches nothing here.
531 if (aOk) artefact[a] = (artefact[a] || 0) + 1;
532 if (bOk) artefact[b] = (artefact[b] || 0) + 1;
533 if (!aOk && !bOk && (a !== null || b !== null)) dangling++;
534 });
535 return { known: known, edges: edges, artefact: artefact, dangling: dangling };
536 }
537
538 /// Which edges close a cycle, and which Diamonds sit on one.
539 ///
540 /// The closing edges come from an iterative depth-first search in id order:
541 /// an edge into a node still on the search stack is a back edge, and which
542 /// edge of a cycle gets called the closing one has to be the same on every
543 /// draw. The Diamonds ON a cycle are a separate question, answered by
544 /// [sccCycles] -- a node can sit on a cycle it joins through a cross edge
545 /// the search never calls "back", so the stack segment a back edge closes
546 /// does not name every member.
547 function findCycles(ids, out) {
548 var back = {}, colour = {};
549 ids.forEach(function (id) {
550 if (colour[id] !== undefined) return;
551 colour[id] = 1;
552 var stack = [{ id: id, i: 0 }];
553 while (stack.length) {
554 var top = stack[stack.length - 1];
555 var os = out[top.id] || [];
556 if (top.i < os.length) {
557 var e = os[top.i++];
558 var c = colour[e.to];
559 if (c === undefined) {
560 colour[e.to] = 1;
561 stack.push({ id: e.to, i: 0 });
562 } else if (c === 1) {
563 back[e.id] = true;
564 }
565 } else {
566 colour[top.id] = 2;
567 stack.pop();
568 }
569 }
570 });
571 return { back: back, onCycle: sccCycles(ids, out) };
572 }
573
574 /// The Diamonds whose strongly connected component holds more than
575 /// themselves, which is exactly the Diamonds on a cycle -- the store rejects
576 /// a self-link, so a component of one cannot be one. An iterative Tarjan
577 /// walk in id order, deterministic like everything else here.
578 function sccCycles(ids, out) {
579 var index = {}, low = {}, onStk = {}, stk = [], n = 0, onCycle = {};
580 ids.forEach(function (root) {
581 if (index[root] !== undefined) return;
582 index[root] = low[root] = n++;
583 stk.push(root);
584 onStk[root] = true;
585 var work = [{ id: root, i: 0 }];
586 while (work.length) {
587 var top = work[work.length - 1];
588 var os = out[top.id] || [];
589 if (top.i < os.length) {
590 var to = os[top.i++].to;
591 if (index[to] === undefined) {
592 index[to] = low[to] = n++;
593 stk.push(to);
594 onStk[to] = true;
595 work.push({ id: to, i: 0 });
596 } else if (onStk[to] && index[to] < low[top.id]) {
597 low[top.id] = index[to];
598 }
599 } else {
600 work.pop();
601 if (work.length) {
602 var p = work[work.length - 1].id;
603 if (low[top.id] < low[p]) low[p] = low[top.id];
604 }
605 // This node roots a component: pop the component off.
606 if (low[top.id] === index[top.id]) {
607 var comp = [];
608 for (;;) {
609 var v = stk.pop();
610 onStk[v] = false;
611 comp.push(v);
612 if (v === top.id) break;
613 }
614 if (comp.length > 1) comp.forEach(function (v) { onCycle[v] = true; });
615 }
616 }
617 }
618 });
619 return onCycle;
620 }
621
622 /// Put every connected Diamond on a layer: the length of the longest chain of
623 /// links ending at it. A Diamond nothing points at is on layer 0, at the top,
624 /// so the arrows read downwards.
625 ///
626 /// Back edges are left out of this, which is what makes it terminate: a cycle
627 /// has no longest path. They are drawn afterwards, over the layering the rest
628 /// of the graph settled.
629 function layerise(ids, edges, back) {
630 var indeg = {}, out = {}, layer = {};
631 ids.forEach(function (id) { indeg[id] = 0; out[id] = []; layer[id] = 0; });
632 edges.forEach(function (e) {
633 if (back[e.id]) return;
634 out[e.from].push(e);
635 indeg[e.to]++;
636 });
637 var ready = ids.filter(function (id) { return indeg[id] === 0; });
638 // The layer numbers are decided by the graph, not by the order this
639 // queue happens to drain, so no ordering is imposed on it.
640 while (ready.length) {
641 var v = ready.shift();
642 out[v].forEach(function (e) {
643 if (layer[v] + 1 > layer[e.to]) layer[e.to] = layer[v] + 1;
644 if (--indeg[e.to] === 0) ready.push(e.to);
645 });
646 }
647 return layer;
648 }
649
650 /// Order the nodes across each layer so fewer lines cross.
651 ///
652 /// The classic barycentre heuristic: a node wants to sit at the average
653 /// horizontal position of what it is joined to, and the layers are swept
654 /// downwards and upwards a few times. Every step is a stable sort over an
655 /// order that began as the Diamond ids, so it converges to one arrangement
656 /// rather than merely a good one.
657 function order(layers, edges, back) {
658 var preds = {}, succs = {};
659 edges.forEach(function (e) {
660 if (back[e.id]) return;
661 (preds[e.to] || (preds[e.to] = [])).push(e.from);
662 (succs[e.from] || (succs[e.from] = [])).push(e.to);
663 });
664 // Where a node sits across its own layer, from 0 to 1, so layers holding
665 // different numbers of Diamonds can still be averaged against each other.
666 var at = {};
667 function reindex() {
668 layers.forEach(function (row) {
669 row.forEach(function (id, i) { at[id] = row.length > 1 ? i / (row.length - 1) : 0.5; });
670 });
671 }
672 function sweep(L, side) {
673 var row = layers[L];
674 if (!row || row.length < 2) return;
675 var key = {};
676 row.forEach(function (id) {
677 var ns = side[id] || [];
678 if (!ns.length) { key[id] = at[id]; return; }
679 var sum = 0;
680 ns.forEach(function (n) { sum += at[n]; });
681 key[id] = sum / ns.length;
682 });
683 // A stable sort, so equal keys keep the order they already had --
684 // which traces back, through every sweep, to the Diamond ids.
685 row.sort(function (a, b) { return key[a] - key[b]; });
686 reindex();
687 }
688 reindex();
689 for (var it = 0; it < SWEEPS; it++) {
690 for (var L = 1; L < layers.length; L++) sweep(L, preds);
691 for (var M = layers.length - 2; M >= 0; M--) sweep(M, succs);
692 }
693 }
694
695 // ── Geometry ───────────────────────────────────────────────
696
697 /// Work out where every box goes when nothing has been dragged. Pure
698 /// arithmetic over the ordered layers.
699 function geometry(layers, isolates) {
700 var pos = {}; // diamond id -> { x, y } of the box's top-left corner
701 var connW = 0;
702 layers.forEach(function (row) {
703 var w = row.length ? row.length * NODE_W + (row.length - 1) * H_GAP : 0;
704 if (w > connW) connW = w;
705 });
706 // The unlinked band wraps to the width the connected picture already
707 // needs, so it never widens the drawing on its own account.
708 var perRow = connW > 0 ? Math.max(1, Math.floor((connW + H_GAP) / (NODE_W + H_GAP)))
709 : Math.min(Math.max(isolates.length, 1), 6);
710 if (isolates.length && perRow > isolates.length) perRow = isolates.length;
711 var isoRows = Math.ceil(isolates.length / perRow);
712 var isoW = isolates.length ? perRow * NODE_W + (perRow - 1) * H_GAP : 0;
713 var contentW = Math.max(connW, isoW);
714
715 layers.forEach(function (row, L) {
716 var w = row.length ? row.length * NODE_W + (row.length - 1) * H_GAP : 0;
717 var x0 = PAD + (contentW - w) / 2;
718 var y = PAD + L * (NODE_H + V_GAP);
719 row.forEach(function (id, i) { pos[id] = { x: x0 + i * (NODE_W + H_GAP), y: y }; });
720 });
721
722 var bandY = layers.length ? PAD + layers.length * (NODE_H + V_GAP) - V_GAP + ISO_GAP : PAD;
723 isolates.forEach(function (id, i) {
724 var r = Math.floor(i / perRow), c = i % perRow;
725 var n = Math.min(perRow, isolates.length - r * perRow);
726 var w = n * NODE_W + (n - 1) * H_GAP;
727 pos[id] = {
728 x: PAD + (contentW - w) / 2 + c * (NODE_W + H_GAP),
729 y: bandY + BAND_H + r * (NODE_H + H_GAP),
730 };
731 });
732
733 var h = PAD;
734 if (layers.length) h = PAD + layers.length * (NODE_H + V_GAP) - V_GAP;
735 if (isolates.length) h = bandY + BAND_H + isoRows * (NODE_H + H_GAP) - H_GAP;
736 return { pos: pos, contentW: contentW, bandY: bandY, height: h + PAD, perRow: perRow };
737 }
738
739 /// The point a fraction `t` along a cubic bezier, which is where an edge's
740 /// label sits -- halfway for a lone edge, and a little further along or back
741 /// for one of a parallel group.
742 ///
743 /// Rounded to a thousandth of a unit, which is far finer than a pixel and
744 /// keeps the coordinate short in the serialised picture.
745 function pointAt(p0, p1, p2, p3, t) {
746 var u = 1 - t;
747 var a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t;
748 var r = function (v) { return Math.round(v * 1000) / 1000; };
749 return {
750 x: r(a * p0.x + b * p1.x + c * p2.x + d * p3.x),
751 y: r(a * p0.y + b * p1.y + c * p2.y + d * p3.y),
752 };
753 }
754
755 // ── Drawing ────────────────────────────────────────────────
756
757 /// The two arrowheads, which have to be declared before they are pointed at.
758 function defs() {
759 var d = el('defs');
760 [['gm-arrow', 'graph-arrow'], ['gm-arrow-back', 'graph-arrow back'],
761 ['gm-arrow-live', 'graph-arrow live']].forEach(function (pair) {
762 var m = attrs(el('marker'), {
763 id: pair[0], viewBox: '0 0 10 10', refX: 9, refY: 5,
764 markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse',
765 });
766 var p = attrs(el('path', pair[1]), { d: 'M 0 0 L 10 5 L 0 10 z' });
767 m.appendChild(p);
768 d.appendChild(m);
769 });
770 return d;
771 }
772
773 /// The outline of a box: horizontal top and bottom, and two sides kinked out
774 /// to a point at the middle.
775 ///
776 /// One string, because every box is one size. The points are where the lines
777 /// meet it — see [port] — so the shape and the wiring cannot drift apart.
778 var BOX_D = 'M' + KINK + ',0'
779 + ' H' + (NODE_W - KINK)
780 + ' L' + NODE_W + ',' + (NODE_H / 2)
781 + ' L' + (NODE_W - KINK) + ',' + NODE_H
782 + ' H' + KINK
783 + ' L0,' + (NODE_H / 2)
784 + ' Z';
785
786 /// A point on a box's outline: which side, and how far along it.
787 ///
788 /// # Arguments
789 /// * `p` - The box's top-left corner.
790 /// * `side` - `top`, `bottom`, `left` or `right`.
791 /// * `f` - How far along that side, from 0 to 1.
792 ///
793 /// The top and bottom run corner to corner, which is the box's width less
794 /// the two kinks. The sides are not straight, so the point is pulled in by
795 /// as much of the kink as it stands away from the middle — which is what
796 /// keeps an arrowhead ON the edge it lands against rather than beside it.
797 function port(p, side, f) {
798 var off = KINK * Math.abs(1 - 2 * f);
799 if (side === 'top') return { x: p.x + KINK + f * (NODE_W - 2 * KINK), y: p.y };
800 if (side === 'bottom') return { x: p.x + KINK + f * (NODE_W - 2 * KINK), y: p.y + NODE_H };
801 if (side === 'left') return { x: p.x + off, y: p.y + f * NODE_H };
802 return { x: p.x + NODE_W - off, y: p.y + f * NODE_H };
803 }
804
805 /// Which of the three ports on a side faces the other box, given how far off
806 /// centre it lies and how much of an offset counts as "off centre".
807 function portFor(d, span) {
808 if (d > span) return PORTS[2];
809 if (d < -span) return PORTS[0];
810 return PORTS[1];
811 }
812
813 /// One Diamond.
814 function nodeEl(d, p, marks) {
815 var g = el('g', 'graph-node' + (marks.isolate ? ' isolate' : '')
816 + (marks.cycle ? ' cycled' : '') + (marks.placed ? ' placed' : '')
817 + (marks.source ? ' link-source' : ''));
818 g.setAttribute('data-diamond-id', d.id);
819 g.setAttribute('transform', 'translate(' + p.x + ',' + p.y + ')');
820 // A colour chosen for this Diamond elsewhere, handed to the stylesheet as
821 // a property rather than painted straight on: the rules below it keep
822 // their fallbacks, so a Diamond with no colour of its own still follows
823 // the theme through every state it has.
824 if (marks.bg) g.style.setProperty('--node-bg', marks.bg);
825 if (marks.fg) g.style.setProperty('--node-fg', marks.fg);
826 g.appendChild(attrs(el('path', 'graph-node-box'), { d: BOX_D }));
827 var name = d.name && d.name.trim() ? d.name : t('graph.unnamed');
828 var tx = attrs(el('text', 'graph-node-name'), { x: 12, y: NODE_H / 2 + 4 });
829 tx.textContent = clip(name);
830 g.appendChild(tx);
831 tip(g, name);
832
833 if (marks.artefacts) {
834 var b = el('g', 'graph-badge');
835 b.setAttribute('transform', 'translate(' + (NODE_W - 10) + ',' + 14 + ')');
836 var bt = attrs(el('text', 'graph-badge-text'), { x: 0, y: 0 });
837 bt.textContent = '◈ ' + marks.artefacts;
838 b.appendChild(bt);
839 tip(b, tn('graph.artefacts', marks.artefacts));
840 g.appendChild(b);
841 }
842 if (marks.cycle) {
843 var c = el('g', 'graph-cycle');
844 c.setAttribute('transform', 'translate(' + (NODE_W - 10) + ',' + (NODE_H - 9) + ')');
845 var ct = attrs(el('text', 'graph-cycle-text'), { x: 0, y: 0 });
846 ct.textContent = '⟲';
847 c.appendChild(ct);
848 tip(c, t('graph.in_cycle'));
849 g.appendChild(c);
850 }
851 return g;
852 }
853
854 /// Hold a fraction to its own side. Parallel lines are held apart by moving
855 /// where they meet the box, and a line held so far apart that it left the box
856 /// altogether would point at nothing.
857 function clamp01(f) { return f < 0 ? 0 : f > 1 ? 1 : f; }
858
859 /// Where an edge leaves, where it lands, and the two control points that bow
860 /// it between them.
861 ///
862 /// The LAYERED case is the ordinary one -- out of the bottom of the source,
863 /// into the top of the target -- and it is the only case auto-layout can
864 /// produce, because a forward edge's target is always on a lower layer.
865 ///
866 /// Dragging can put a target level with its source or above it, and there
867 /// bottom-to-top would draw a line doubling back through both boxes. Only
868 /// that case anchors elsewhere: on the facing sides when the two are mostly
869 /// side by side, and top-to-bottom when the target is mostly above. The
870 /// arrowhead therefore always lands on the edge of the box it points at and
871 /// never inside it.
872 ///
873 /// WHICH POINT of a side, though, is no longer always the middle. Each side
874 /// offers three, and a line takes the one that faces where it is going: a
875 /// target well to the right is left from the right-hand port and entered at
876 /// the left-hand one. Four boxes hanging off one used to leave it through a
877 /// single point and cross each other doing it. This does change the picture a
878 /// store already draws -- the lines move, the boxes do not -- so it is not
879 /// the byte-for-byte picture of before, and it is still one picture per
880 /// store.
881 ///
882 /// `nudge`, which holds parallel links apart, is spent along the same side:
883 /// it used to be pixels added to a coordinate, and pixels could push a line's
884 /// end clean off the box it belonged to. Along the top and bottom the two
885 /// come to the same distance; along a side it is bounded by the side.
886 function route(a, b, nudge) {
887 var ac = { x: a.x + NODE_W / 2, y: a.y + NODE_H / 2 };
888 var bc = { x: b.x + NODE_W / 2, y: b.y + NODE_H / 2 };
889 var dx = bc.x - ac.x, dv = bc.y - ac.y;
890 var flat = NODE_W - 2 * KINK; // the length of the top and bottom edges
891 var p0, p1, p2, p3, f, n;
892 if (bc.y > ac.y) {
893 f = portFor(dx, NODE_W / 2);
894 n = nudge / flat;
895 p0 = port(a, 'bottom', clamp01(f + n));
896 p3 = port(b, 'top', clamp01(1 - f + n));
897 var dy = Math.max(24, p3.y - p0.y);
898 p1 = { x: p0.x, y: p0.y + dy * 0.42 };
899 p2 = { x: p3.x, y: p3.y - dy * 0.42 };
900 return { p0: p0, p1: p1, p2: p2, p3: p3 };
901 }
902 if (Math.abs(dx) >= Math.abs(dv)) {
903 var right = dx >= 0;
904 f = portFor(dv, NODE_H / 2);
905 n = nudge / NODE_H;
906 p0 = port(a, right ? 'right' : 'left', clamp01(f + n));
907 p3 = port(b, right ? 'left' : 'right', clamp01(1 - f + n));
908 var run = Math.max(24, Math.abs(p3.x - p0.x)) * 0.42 * (right ? 1 : -1);
909 p1 = { x: p0.x + run, y: p0.y };
910 p2 = { x: p3.x - run, y: p3.y };
911 return { p0: p0, p1: p1, p2: p2, p3: p3 };
912 }
913 f = portFor(dx, NODE_W / 2);
914 n = nudge / flat;
915 p0 = port(a, 'top', clamp01(f + n));
916 p3 = port(b, 'bottom', clamp01(1 - f + n));
917 var rise = Math.max(24, p0.y - p3.y) * 0.42;
918 p1 = { x: p0.x, y: p0.y - rise };
919 p2 = { x: p3.x, y: p3.y + rise };
920 return { p0: p0, p1: p1, p2: p2, p3: p3 };
921 }
922
923 // The hues a chip can take, and the hash that picks one. Both are copied from
924 // `tagHue` in daimond.js, deliberately and not happily: a tag has to be one
925 // colour wherever it appears, and the rail's chips are drawn by a function
926 // this module cannot reach. If either side is ever changed, the other has to
927 // change with it -- which is the argument for lifting the pair somewhere
928 // both can call.
929 var TAG_HUES = [10, 40, 75, 145, 190, 220, 265, 315];
930
931 /// A relation's hue, hashed from its name, so one relation is one colour
932 /// everywhere and stays that colour across reloads.
933 function hueOf(word) {
934 var h = 0;
935 for (var i = 0; i < word.length; i++) {
936 h = ((h << 5) - h + word.charCodeAt(i)) | 0; // 31*h + c, 32-bit
937 }
938 h ^= h >>> 15;
939 h = Math.imul(h, 0x85ebca6b) | 0;
940 h ^= h >>> 13;
941 return TAG_HUES[Math.abs(h) % TAG_HUES.length];
942 }
943
944 /// Roughly how wide a word comes out, without asking the page.
945 ///
946 /// A pill has to be as wide as the word inside it, and measuring the word is
947 /// the one thing this module may not do -- the answer would differ between
948 /// two machines and the two pictures would differ with it. So the chip's type
949 /// is pinned at [CHIP_FS] in the stylesheet and its width is estimated here,
950 /// from a per-character advance in three classes. It errs wide: a pill a
951 /// little roomy reads better than one a letter short.
952 function wordW(s) {
953 var em = 0;
954 for (var i = 0; i < s.length; i++) {
955 var c = s.charAt(i);
956 if (s.charCodeAt(i) > 0x2e80) em += 1.0; // CJK and the like
957 else if ('iljtIf.,:;\'!|` '.indexOf(c) >= 0) em += 0.34;
958 else if ('mwMW@'.indexOf(c) >= 0) em += 0.92;
959 else if (c >= 'A' && c <= 'Z') em += 0.68;
960 else em += 0.56;
961 }
962 return Math.round(em * CHIP_FS * 10) / 10;
963 }
964
965 /// The relations of one link, as chips stacked about the origin.
966 ///
967 /// Stacked rather than strung out along the line: a row of chips would grow
968 /// sideways across whatever the line runs beside, and a link with three
969 /// relations would cover a box. The caller translates the whole group to the
970 /// point on the line the chips belong to; each chip is opaque, so the line
971 /// runs up to the edge of the stack and no further.
972 function chipStack(rels) {
973 var g = el('g', 'graph-chips');
974 var total = rels.length * CHIP_H + (rels.length - 1) * CHIP_GAP;
975 rels.forEach(function (word, i) {
976 var w = wordW(word) + CHIP_PAD * 2;
977 var y = -total / 2 + i * (CHIP_H + CHIP_GAP);
978 var c = el('g', 'graph-chip');
979 c.style.setProperty('--tag-h', hueOf(word));
980 c.appendChild(attrs(el('rect', 'graph-chip-box'), {
981 x: -w / 2, y: y, width: w, height: CHIP_H,
982 rx: CHIP_H / 2, ry: CHIP_H / 2,
983 }));
984 // The class the relation has always been written in, kept: this text
985 // IS the edge's label, whatever is drawn behind it.
986 var tx = attrs(el('text', 'graph-edge-label'), { x: 0, y: y + CHIP_H / 2 });
987 tx.textContent = word;
988 c.appendChild(tx);
989 g.appendChild(c);
990 });
991 return g;
992 }
993
994 /// The line one link is drawn as, and the point its chips sit at.
995 ///
996 /// Split out of [edgeEl] because a drag recomputes exactly this, for every
997 /// line touching the box being moved, on each pointer move. `w` carries what
998 /// the draw decided about this link and a drag must not decide again: whether
999 /// it closes a cycle, how far it is held off its parallel neighbours, how far
1000 /// it bows, and how far along it its chips ride.
1001 function edgeGeom(e, pos, w) {
1002 var a = pos[e.from], b = pos[e.to];
1003 var p0, p1, p2, p3;
1004 if (w.isBack) {
1005 // Out to the right of everything and back, so a closing edge never
1006 // reads as one more step down the hierarchy.
1007 p0 = port(a, 'right', 0.5);
1008 p3 = port(b, 'right', 0.5);
1009 p1 = { x: p0.x + w.bow, y: p0.y };
1010 p2 = { x: p3.x + w.bow, y: p3.y };
1011 } else {
1012 var r = route(a, b, w.nudge);
1013 p0 = r.p0; p1 = r.p1; p2 = r.p2; p3 = r.p3;
1014 }
1015 return {
1016 d: 'M' + p0.x + ',' + p0.y + ' C' + p1.x + ',' + p1.y
1017 + ' ' + p2.x + ',' + p2.y + ' ' + p3.x + ',' + p3.y,
1018 at: pointAt(p0, p1, p2, p3, w.labelT),
1019 };
1020 }
1021
1022 /// One link, arrowed from its `from` end to its `to` end. `w.labelT` says how
1023 /// far along the line its chips ride, which is what holds the relations of two
1024 /// links between one pair apart.
1025 function edgeEl(e, pos, w, names) {
1026 var g = el('g', 'graph-edge' + (w.isBack ? ' back' : ''));
1027 g.setAttribute('data-link-id', e.id);
1028 g.setAttribute('data-from', e.from);
1029 g.setAttribute('data-to', e.to);
1030 g.setAttribute('data-owner', e.owner || '');
1031
1032 var geo = edgeGeom(e, pos, w);
1033 // A wide invisible twin under the line, because 1.4 pixels of stroke is
1034 // not something a pointer can be asked to hit. It carries the clicks; the
1035 // drawn line carries the look.
1036 g.appendChild(attrs(el('path', 'graph-edge-hit'), { d: geo.d }));
1037 g.appendChild(attrs(el('path', 'graph-edge-line'), {
1038 d: geo.d,
1039 'marker-end': 'url(#' + (w.isBack ? 'gm-arrow-back' : 'gm-arrow') + ')',
1040 }));
1041
1042 var rels = relsOf(e.rel);
1043 var lines = [t('graph.edge_tip', { from: names[e.from], to: names[e.to] })];
1044 if (rels.length) lines.push(t('graph.edge_rel', { rel: rels.join(', ') }));
1045 if (e.note) lines.push(e.note);
1046 if (w.isBack) lines.push(t('graph.back_edge'));
1047 lines.push(t('graph.edit_help'));
1048 tip(g, lines.join('\n'));
1049
1050 if (rels.length) {
1051 var chips = chipStack(rels);
1052 chips.setAttribute('transform', 'translate(' + geo.at.x + ',' + geo.at.y + ')');
1053 g.appendChild(chips);
1054 }
1055 return g;
1056 }
1057
1058 // ── Selecting a Diamond ────────────────────────────────────
1059
1060 /// Open the clicked Diamond in the centre.
1061 ///
1062 /// Through the rail's own box rather than through a function of its own: the
1063 /// rail already turns a click into the whole of what selecting means (the
1064 /// centre, the arrangement, the phone's panel), and a second door into that
1065 /// would be a second thing to keep in step with it.
1066 function select(id) {
1067 var safe = (window.CSS && CSS.escape) ? CSS.escape(id) : String(id).replace(/"/g, '\\"');
1068 var box = document.querySelector('#diamond-list .diamond-box[data-id="' + safe + '"]');
1069 if (!box) {
1070 // The rail is filtered to a tag this Diamond does not carry, so its
1071 // box is not in the document to click.
1072 console.warn('graph: no rail entry for Diamond ' + id);
1073 return;
1074 }
1075 box.click();
1076 }
1077
1078 // ── The draw ───────────────────────────────────────────────
1079
1080 function render(store) {
1081 var c = classify(store);
1082 bodyEl.textContent = '';
1083 lastStore = store;
1084 lastGeo = null;
1085
1086 if (!store.diamonds.length) {
1087 var none = document.createElement('p');
1088 none.className = 'graph-empty';
1089 none.textContent = t('graph.no_diamonds');
1090 bodyEl.appendChild(none);
1091 paintToolbar(false);
1092 return;
1093 }
1094 paintToolbar(true);
1095
1096 var names = {};
1097 store.diamonds.forEach(function (d) {
1098 names[d.id] = (d.name && d.name.trim()) ? d.name : t('graph.unnamed');
1099 });
1100
1101 // Anything left behind by a Diamond that has gone. Done here because this
1102 // is where the live set is known.
1103 if (pruneLayout(store.diamonds.map(function (d) { return d.id; }))) saveLayout();
1104
1105 // Connected and unlinked, both already in id order.
1106 var touched = {};
1107 c.edges.forEach(function (e) { touched[e.from] = true; touched[e.to] = true; });
1108 var ids = store.diamonds.map(function (d) { return d.id; }).filter(function (id) { return touched[id]; });
1109 var isolates = store.diamonds.map(function (d) { return d.id; }).filter(function (id) { return !touched[id]; });
1110
1111 // Out-edges per node, in link-id order, so the cycle search always calls
1112 // the same edge the closing one.
1113 var out = {};
1114 ids.forEach(function (id) { out[id] = []; });
1115 c.edges.forEach(function (e) { out[e.from].push(e); });
1116 ids.forEach(function (id) {
1117 out[id].sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; });
1118 });
1119
1120 var cyc = findCycles(ids, out);
1121 var auto = autoLayout(ids, isolates, c.edges, cyc.back);
1122
1123 // The stored positions, over the computed ones. Auto-layout never reads
1124 // them, so moving one Diamond cannot move another: what has not been
1125 // dragged sits exactly where it sat.
1126 var pos = {}, placed = {};
1127 Object.keys(auto.pos).forEach(function (id) { pos[id] = { x: auto.pos[id].x, y: auto.pos[id].y }; });
1128 Object.keys(pos).forEach(function (id) {
1129 var s = storedPos(id);
1130 if (s) { pos[id] = s; placed[id] = true; }
1131 });
1132
1133 // Back edges bow out to the right; the widest of them decides the
1134 // drawing's right margin.
1135 var backList = c.edges.filter(function (e) { return cyc.back[e.id]; });
1136 var bows = {}, maxBow = 0;
1137 backList.forEach(function (e, i) {
1138 var b = 48 + i * 20;
1139 bows[e.id] = b;
1140 if (b > maxBow) maxBow = b;
1141 });
1142
1143 // The ink is the boxes plus their margin, measured off the boxes rather
1144 // than off the layout, so a dragged Diamond can be scrolled to.
1145 var maxX = 0, maxY = 0;
1146 Object.keys(pos).forEach(function (id) {
1147 if (pos[id].x + NODE_W > maxX) maxX = pos[id].x + NODE_W;
1148 if (pos[id].y + NODE_H > maxY) maxY = pos[id].y + NODE_H;
1149 });
1150 var inkW = maxX + PAD + (maxBow ? maxBow + 24 : 0);
1151 var inkH = maxY + PAD;
1152 // And the canvas runs [ROOM] past it. The drawing used to stop dead where
1153 // the last box did, so the edge of the picture chased the Diamonds about
1154 // and there was nowhere to put one but where one already was. Room to the
1155 // right and below only: a position is never negative (see [putPos]), so
1156 // the other two edges are the origin and there is nothing past them.
1157 var width = inkW + ROOM;
1158 var height = inkH + ROOM;
1159 var zoom = saneZoom(loadLayout().zoom);
1160
1161 // The scale is a property of this window, not of the picture: the viewBox
1162 // is the drawing's own coordinates and only the size it is presented at
1163 // moves, so every coordinate below is the coordinate the store implies.
1164 var svg = attrs(el('svg', 'graph-svg'), {
1165 viewBox: '0 0 ' + width + ' ' + height,
1166 width: Math.round(width * zoom), height: Math.round(height * zoom),
1167 xmlns: SVGNS,
1168 });
1169 svg.setAttribute('id', 'graph-svg');
1170 svg.appendChild(defs());
1171
1172 // Parallel links between one pair are nudged apart, so two relations
1173 // between the same two Diamonds are two visible lines. The lines alone were
1174 // not enough: each label sat at the middle of its OWN line, so two long
1175 // relations were written one over the other however far apart the lines they
1176 // belong to had been pushed -- eighteen pixels is not a word. So a lane moves
1177 // both: the line sideways, and the label along the line, where the room is.
1178 //
1179 // The lanes are handed out in the edges' own order, which is link-id order, so
1180 // the same store gives the same link the same lane on every draw.
1181 var groups = {};
1182 c.edges.forEach(function (e) {
1183 var k = e.from + ' ' + e.to;
1184 (groups[k] || (groups[k] = [])).push(e);
1185 });
1186
1187 // What the draw decides about each line, kept so a drag can redraw it
1188 // without deciding any of it again.
1189 var wire = {};
1190 var edgesG = el('g', 'graph-edges');
1191 c.edges.forEach(function (e) {
1192 var isBack = !!cyc.back[e.id];
1193 var grp = groups[e.from + ' ' + e.to];
1194 var lane = grp.length > 1 ? grp.indexOf(e) - (grp.length - 1) / 2 : 0;
1195 // A closing edge is already held off its neighbours by its own bow, so
1196 // only a forward line is moved sideways; the chips move either way.
1197 wire[e.id] = {
1198 isBack: isBack,
1199 nudge: isBack ? 0 : lane * NUDGE,
1200 bow: bows[e.id] || 0,
1201 labelT: 0.5 + lane * Math.min(LABEL_T, LABEL_SPAN / grp.length),
1202 };
1203 edgesG.appendChild(edgeEl(e, pos, wire[e.id], names));
1204 });
1205 svg.appendChild(edgesG);
1206
1207 var colours = tileColours();
1208 var nodesG = el('g', 'graph-nodes');
1209 store.diamonds.forEach(function (d) {
1210 if (!pos[d.id]) return;
1211 var col = colours[d.id] || {};
1212 nodesG.appendChild(nodeEl(d, pos[d.id], {
1213 artefacts: c.artefact[d.id] || 0,
1214 cycle: !!cyc.onCycle[d.id],
1215 isolate: !touched[d.id],
1216 placed: !!placed[d.id],
1217 source: link.from === d.id,
1218 bg: col.bg,
1219 fg: col.fg,
1220 }));
1221 });
1222 svg.appendChild(nodesG);
1223
1224 if (isolates.length) {
1225 // The heading tracks the boxes it heads rather than the place the
1226 // layout would have put them, so dragging one does not leave the words
1227 // pointing at nothing. Where nothing has moved this is the same y it
1228 // always was: the band's first row sits BAND_H below bandY.
1229 var isoTop = Infinity;
1230 isolates.forEach(function (id) { if (pos[id] && pos[id].y < isoTop) isoTop = pos[id].y; });
1231 var band = attrs(el('text', 'graph-band'), { x: PAD, y: isoTop - 10 });
1232 band.textContent = t('graph.isolated');
1233 svg.appendChild(band);
1234 }
1235
1236 // The layer the pointer feedback is drawn in, and the reason the guarantee
1237 // survives it: nothing here is ever written, and it is empty except during
1238 // a gesture.
1239 svg.appendChild(el('g', 'graph-live'));
1240
1241 // Nothing is said here about a store that holds Diamonds but no links. The
1242 // line that used to be said -- that the picture appears here once two
1243 // Diamonds are linked -- was put ABOVE the picture, and the picture was not
1244 // missing: every one of those Diamonds was drawn directly below it, in the
1245 // band headed "not linked". A sentence promising what is already under it,
1246 // and pointing at the place it is standing in, is worse than no sentence.
1247 // The band and the stats line say the same thing where the thing is.
1248 bodyEl.appendChild(svg);
1249
1250 lastGeo = {
1251 pos: pos, edges: c.edges, names: names,
1252 width: width, height: height,
1253 // The ink alone, which is what "All" scales to fit -- fitting the
1254 // canvas would fit the empty room with it.
1255 inkW: inkW, inkH: inkH,
1256 wire: wire, zoom: zoom,
1257 };
1258 wireCanvas(svg);
1259 if (link.from) drawLive(link.at);
1260
1261 var backCount = backList.length;
1262 var stats = [
1263 tn('graph.stat_diamonds', store.diamonds.length),
1264 tn('graph.stat_links', c.edges.length),
1265 ];
1266 if (backCount) stats.push(tn('graph.stat_cycles', backCount));
1267 if (c.dangling) stats.push(tn('graph.stat_dangling', c.dangling));
1268 var line = document.createElement('div');
1269 line.className = 'graph-stats';
1270 line.textContent = stats.join(' · ');
1271 bodyEl.appendChild(line);
1272
1273 restorePan();
1274 }
1275
1276 /// Where the layout would put every Diamond, ignoring anything stored.
1277 ///
1278 /// Split out of the draw because "organise" needs exactly this and nothing
1279 /// else: the coordinates the instrument would choose, which it then writes
1280 /// down. Two calls on one store give one answer, which is what makes
1281 /// organising twice the same as organising once.
1282 function autoLayout(ids, isolates, edges, back) {
1283 var layer = layerise(ids, edges, back);
1284 var depth = 0;
1285 ids.forEach(function (id) { if (layer[id] > depth) depth = layer[id]; });
1286 var layers = [];
1287 for (var L = 0; L <= depth && ids.length; L++) layers.push([]);
1288 ids.forEach(function (id) { layers[layer[id]].push(id); });
1289 order(layers, edges, back);
1290 return geometry(layers, isolates);
1291 }
1292
1293 // ── Organising ─────────────────────────────────────────────
1294
1295 /// Lay every Diamond out again, and WRITE where the layout put it.
1296 ///
1297 /// What it optimises, stated plainly because "reduce clutter" is not a
1298 /// specification. It does three things and no more:
1299 ///
1300 /// 1. It puts every connected Diamond on a layer equal to the longest chain
1301 /// of links ending at it, so every forward arrow reads downwards and the
1302 /// depth of the structure is the height of the picture.
1303 /// 2. It orders each layer by the barycentre heuristic -- four sweeps down
1304 /// and up, a node pulled towards the average position of its neighbours
1305 /// -- which REDUCES crossings between adjacent layers. It does not
1306 /// minimise them: the minimum crossing number is NP-hard, and a
1307 /// heuristic that lands on it is lucky rather than correct.
1308 /// 3. It staggers the labels of parallel links along their own lines, so
1309 /// two relations between one pair are two readable words.
1310 ///
1311 /// What it does NOT do: it does not shorten edges, it does not straighten
1312 /// long ones through dummy nodes (so a link spanning four layers still cuts
1313 /// across the three between), it does not consider the WIDTH of a name, and
1314 /// it does not try to keep a label off a line it does not belong to. Nor does
1315 /// it read the positions it is about to overwrite -- which is why running it
1316 /// twice from one store gives one picture.
1317 function organise() {
1318 if (!lastStore) return refresh();
1319 var c = classify(lastStore);
1320 var touched = {};
1321 c.edges.forEach(function (e) { touched[e.from] = true; touched[e.to] = true; });
1322 var ids = lastStore.diamonds.map(function (d) { return d.id; }).filter(function (id) { return touched[id]; });
1323 var isolates = lastStore.diamonds.map(function (d) { return d.id; }).filter(function (id) { return !touched[id]; });
1324 var out = {};
1325 ids.forEach(function (id) { out[id] = []; });
1326 c.edges.forEach(function (e) { out[e.from].push(e); });
1327 ids.forEach(function (id) {
1328 out[id].sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; });
1329 });
1330 var cyc = findCycles(ids, out);
1331 var auto = autoLayout(ids, isolates, c.edges, cyc.back);
1332 // One stamp for the whole arrangement: organising is one act, and giving
1333 // each box its own millisecond would let a sync interleave two of them.
1334 var now = Date.now();
1335 Object.keys(auto.pos).forEach(function (id) {
1336 putPos(id, auto.pos[id].x, auto.pos[id].y, now);
1337 });
1338 saveLayout();
1339 redraw();
1340 }
1341
1342 /// Forget every stored position, so the whole picture is computed again.
1343 function resetAll() {
1344 var l = loadLayout();
1345 l.pos = {};
1346 saveLayout();
1347 redraw();
1348 }
1349
1350 // ── The view ───────────────────────────────────────────────
1351
1352 // Set while the view is being put back, so the scroll that causes does not
1353 // come back round as a scroll the user made. A panel drawn before it has been
1354 // laid out has no room to scroll in, and saving the nought the browser
1355 // clamped to would lose the pan on every redraw.
1356 var restoring = false;
1357
1358 /// Put the view back where it was left. The picture is panned by scrolling,
1359 /// so the pan IS the scroll offset.
1360 ///
1361 /// It assigns even when the offset is nought, because "All" leaves the view
1362 /// at the origin and the scroller may be a long way from it. What it will not
1363 /// do is assign an offset the scroller already has, which is a scroll event
1364 /// for nothing on every redraw.
1365 function restorePan() {
1366 if (!bodyEl) return;
1367 var p = loadLayout().pan;
1368 if (bodyEl.scrollLeft === p.x && bodyEl.scrollTop === p.y) return;
1369 restoring = true;
1370 bodyEl.scrollLeft = p.x;
1371 bodyEl.scrollTop = p.y;
1372 setTimeout(function () { restoring = false; }, 0);
1373 }
1374
1375 /// Scale the picture until all of it is on screen, and go to the origin.
1376 ///
1377 /// The window's size is asked for here, and nowhere else that matters: the
1378 /// answer sets the size the SVG is PRESENTED at and never a coordinate, so
1379 /// the arrangement two people compare is the same arrangement at two sizes.
1380 /// It never enlarges past full size -- see [ZOOM_MAX].
1381 function fitAll() {
1382 if (!bodyEl || !lastGeo) return;
1383 var vw = Math.max(40, bodyEl.clientWidth - 8);
1384 var vh = Math.max(40, bodyEl.clientHeight - 8);
1385 var z = saneZoom(Math.min(vw / Math.max(1, lastGeo.inkW), vh / Math.max(1, lastGeo.inkH)));
1386 var l = loadLayout();
1387 l.zoom = z;
1388 l.pan = { x: 0, y: 0 };
1389 saveLayout();
1390 redraw();
1391 }
1392
1393 function savePan() {
1394 if (!bodyEl || restoring) return;
1395 var l = loadLayout();
1396 l.pan = { x: Math.round(bodyEl.scrollLeft), y: Math.round(bodyEl.scrollTop) };
1397 saveLayout();
1398 }
1399
1400 /// Full size, at the origin. The way back from "All" as well as from a pan,
1401 /// since both are the same view state.
1402 function resetView() {
1403 var l = loadLayout();
1404 l.pan = { x: 0, y: 0 };
1405 l.zoom = 1;
1406 saveLayout();
1407 if (!bodyEl) return;
1408 redraw();
1409 }
1410
1411 // ── The toolbar ────────────────────────────────────────────
1412
1413 var bar = null, barLink = null, barOrg = null, barAll = null, barSay = null;
1414
1415 /// The strip above the picture. Built once, into the panel rather than into
1416 /// the scrolling body, so it neither scrolls away nor is wiped by a redraw.
1417 function buildToolbar() {
1418 var p = panelEl();
1419 if (!p || !bodyEl || bar) return;
1420 bar = h('div', 'graph-bar');
1421
1422 barLink = h('button', 'graph-btn', t('graph.link_mode'));
1423 barLink.type = 'button';
1424 barLink.id = 'graph-link-btn';
1425 barLink.title = t('graph.link_help');
1426 barLink.setAttribute('aria-pressed', 'false');
1427 barLink.addEventListener('click', function () { toggleLinkMode(); });
1428
1429 barOrg = h('button', 'graph-btn', t('graph.organise'));
1430 barOrg.type = 'button';
1431 barOrg.id = 'graph-organise-btn';
1432 barOrg.title = t('graph.organise_help');
1433 barOrg.addEventListener('click', function () { organise(); });
1434
1435 barAll = h('button', 'graph-btn', t('graph.fit'));
1436 barAll.type = 'button';
1437 barAll.id = 'graph-all-btn';
1438 barAll.title = t('graph.fit_help');
1439 barAll.addEventListener('click', function () { fitAll(); });
1440
1441 barSay = h('span', 'graph-say', '');
1442 barSay.id = 'graph-say';
1443
1444 bar.appendChild(barLink);
1445 bar.appendChild(barOrg);
1446 bar.appendChild(barAll);
1447 bar.appendChild(barSay);
1448 p.insertBefore(bar, bodyEl);
1449 }
1450
1451 function paintToolbar(on) {
1452 buildToolbar();
1453 if (!bar) return;
1454 bar.style.display = on ? '' : 'none';
1455 if (!barLink) return;
1456 barLink.textContent = t('graph.link_mode');
1457 barLink.title = t('graph.link_help');
1458 barLink.classList.toggle('on', !!link.armed);
1459 barLink.setAttribute('aria-pressed', link.armed ? 'true' : 'false');
1460 barOrg.textContent = t('graph.organise');
1461 barOrg.title = t('graph.organise_help');
1462 barAll.textContent = t('graph.fit');
1463 barAll.title = t('graph.fit_help');
1464 barSay.textContent = link.armed
1465 ? (link.from ? t('graph.pick_target', { name: nameOf(link.from) }) : t('graph.pick_source'))
1466 : '';
1467 }
1468
1469 function nameOf(id) {
1470 if (!lastStore) return id;
1471 var d = lastStore.diamonds.filter(function (x) { return x.id === id; })[0];
1472 return d && d.name && d.name.trim() ? d.name : t('graph.unnamed');
1473 }
1474
1475 // ── Linking by click ───────────────────────────────────────
1476
1477 // `armed` is the mode; `from` is the source once one has been picked; `at` is
1478 // where the pointer was, in picture coordinates, for the live arrow.
1479 var link = { armed: false, from: null, at: null };
1480
1481 function toggleLinkMode(on) {
1482 var want = (on === undefined) ? !link.armed : !!on;
1483 link.armed = want;
1484 if (!want) { link.from = null; link.at = null; }
1485 clearLive();
1486 paintToolbar(true);
1487 if (bodyEl) bodyEl.classList.toggle('linking', link.armed);
1488 redraw();
1489 }
1490
1491 /// Leave link mode. The one way out, so Escape, a click on empty space and
1492 /// the button all end in the same state.
1493 function cancelLink() {
1494 if (!link.armed && !link.from) return false;
1495 toggleLinkMode(false);
1496 return true;
1497 }
1498
1499 /// A pointer position in the picture's own coordinates.
1500 function atPoint(svg, ev) {
1501 var r = svg.getBoundingClientRect();
1502 var vb = (svg.getAttribute('viewBox') || '0 0 1 1').split(/\s+/);
1503 var w = parseFloat(vb[2]) || 1, hgt = parseFloat(vb[3]) || 1;
1504 // The SVG is drawn at its own size, so this is a translation and not a
1505 // scale -- but the ratio is taken anyway, so a stylesheet that ever does
1506 // scale it cannot silently put the arrow somewhere else.
1507 return {
1508 x: (ev.clientX - r.left) * (w / (r.width || w)),
1509 y: (ev.clientY - r.top) * (hgt / (r.height || hgt)),
1510 };
1511 }
1512
1513 function liveLayer() {
1514 var svg = bodyEl && bodyEl.querySelector('svg#graph-svg');
1515 return svg ? svg.querySelector('g.graph-live') : null;
1516 }
1517
1518 function clearLive() {
1519 var g = liveLayer();
1520 if (g) g.textContent = '';
1521 }
1522
1523 /// The arrow that follows the pointer while a link is being drawn.
1524 ///
1525 /// The only moving thing in the module. It is drawn from the source box's
1526 /// nearest edge to wherever the pointer is, it lives in `g.graph-live`, and
1527 /// it is thrown away the moment the gesture ends. Nothing about it is ever
1528 /// stored, so no reload can show it and no two devices can disagree about it.
1529 function drawLive(at) {
1530 var g = liveLayer();
1531 if (!g) return;
1532 g.textContent = '';
1533 if (!link.from || !at || !lastGeo || !lastGeo.pos[link.from]) return;
1534 var a = lastGeo.pos[link.from];
1535 var ac = { x: a.x + NODE_W / 2, y: a.y + NODE_H / 2 };
1536 // Leave the box on the side the pointer is, so the line never starts
1537 // under the box it starts at.
1538 var dx = at.x - ac.x, dy = at.y - ac.y;
1539 var p0 = Math.abs(dx) > Math.abs(dy)
1540 ? { x: dx >= 0 ? a.x + NODE_W : a.x, y: ac.y }
1541 : { x: ac.x, y: dy >= 0 ? a.y + NODE_H : a.y };
1542 g.appendChild(attrs(el('path', 'graph-live-line'), {
1543 d: 'M' + p0.x + ',' + p0.y + ' L' + at.x + ',' + at.y,
1544 'marker-end': 'url(#gm-arrow-live)',
1545 }));
1546 }
1547
1548 /// A click landed on a Diamond while link mode was armed.
1549 function linkClick(id) {
1550 if (!link.from) {
1551 link.from = id;
1552 link.at = null;
1553 paintToolbar(true);
1554 redraw();
1555 return;
1556 }
1557 if (link.from === id) {
1558 // The store refuses a link from a thing to itself, and it is right to:
1559 // a loop on one box says nothing. Say so rather than failing silently.
1560 say(t('graph.self_link'));
1561 return;
1562 }
1563 var from = link.from;
1564 toggleLinkMode(false);
1565 openEditor({ mode: 'new', from: from, to: id });
1566 }
1567
1568 /// A sentence in the toolbar, for a refusal that has nowhere else to go.
1569 function say(msg) {
1570 if (!barSay) return;
1571 barSay.textContent = msg;
1572 barSay.classList.add('warn');
1573 setTimeout(function () {
1574 if (!barSay) return;
1575 barSay.classList.remove('warn');
1576 paintToolbar(true);
1577 }, 2600);
1578 }
1579
1580 // ── Dragging a Diamond ─────────────────────────────────────
1581
1582 var drag = null; // { id, g, svg, button, orig, from, moved, pos, wires }
1583
1584 /// The lines touching one Diamond, with the elements that draw them and what
1585 /// the draw decided about each. Gathered once, at the start of a gesture, so
1586 /// no pointer move has to search the document.
1587 function wiresFor(id, svg) {
1588 var out = [];
1589 if (!lastGeo) return out;
1590 lastGeo.edges.forEach(function (e) {
1591 if (e.from !== id && e.to !== id) return;
1592 var w = lastGeo.wire[e.id];
1593 var g = svg.querySelector('g.graph-edge[data-link-id="' + cssq(e.id) + '"]');
1594 if (!w || !g) return;
1595 out.push({
1596 e: e, w: w,
1597 line: g.querySelector('path.graph-edge-line'),
1598 hit: g.querySelector('path.graph-edge-hit'),
1599 chips: g.querySelector('g.graph-chips'),
1600 });
1601 });
1602 return out;
1603 }
1604
1605 /// # Arguments
1606 /// * `button` - Which button is holding the box: the left, as it always was,
1607 /// or the right, which drags and offers its menu only when the
1608 /// press does not travel.
1609 function startDrag(g, id, ev, svg, button) {
1610 var at = atPoint(svg, ev);
1611 var pos = {};
1612 if (lastGeo) Object.keys(lastGeo.pos).forEach(function (k) { pos[k] = lastGeo.pos[k]; });
1613 drag = {
1614 id: id, g: g, svg: svg, button: button,
1615 orig: lastGeo && lastGeo.pos[id] ? { x: lastGeo.pos[id].x, y: lastGeo.pos[id].y } : { x: 0, y: 0 },
1616 from: at, moved: false,
1617 pos: pos, wires: wiresFor(id, svg),
1618 };
1619 g.classList.add('dragging');
1620 document.addEventListener('mousemove', onDragMove, true);
1621 document.addEventListener('mouseup', onDragUp, true);
1622 // While the right button is holding a box, the browser's own menu is not
1623 // wanted anywhere -- including outside the picture, where this module's
1624 // own handler does not run.
1625 if (button === 2) document.addEventListener('contextmenu', eatMenu, true);
1626 }
1627
1628 function eatMenu(ev) { ev.preventDefault(); }
1629
1630 /// Put the box at a point, and the lines touching it with it.
1631 ///
1632 /// The lines are the same arithmetic the next draw does from the store, run
1633 /// against one position the store does not hold yet. Waiting for the mouseup
1634 /// instead left every line hanging off where the box used to be for as long
1635 /// as the gesture lasted, which is a picture that was wrong while it was
1636 /// being looked at.
1637 function moveTo(x, y) {
1638 if (!drag) return;
1639 drag.g.setAttribute('transform', 'translate(' + x + ',' + y + ')');
1640 drag.pos[drag.id] = { x: x, y: y };
1641 drag.wires.forEach(function (wr) {
1642 var geo = edgeGeom(wr.e, drag.pos, wr.w);
1643 if (wr.line) wr.line.setAttribute('d', geo.d);
1644 if (wr.hit) wr.hit.setAttribute('d', geo.d);
1645 if (wr.chips) wr.chips.setAttribute('transform', 'translate(' + geo.at.x + ',' + geo.at.y + ')');
1646 });
1647 }
1648
1649 function onDragMove(ev) {
1650 if (!drag) return;
1651 var at = atPoint(drag.svg, ev);
1652 var dx = at.x - drag.from.x, dy = at.y - drag.from.y;
1653 if (!drag.moved && Math.abs(dx) < DRAG_MIN && Math.abs(dy) < DRAG_MIN) return;
1654 drag.moved = true;
1655 var x = Math.max(0, Math.round(drag.orig.x + dx));
1656 var y = Math.max(0, Math.round(drag.orig.y + dy));
1657 drag.now = { x: x, y: y };
1658 moveTo(x, y);
1659 ev.preventDefault();
1660 }
1661
1662 function onDragUp(ev) {
1663 if (!drag) return;
1664 var d = drag;
1665 endDrag();
1666 if (!d.moved || !d.now) {
1667 // A right press that went nowhere is not a drag; it is the menu, held
1668 // back until the button came up so that moving would have cancelled
1669 // it. Any other button ending the gesture just ends it, rather than
1670 // standing in for the one that was pressed.
1671 if (d.button === 2 && ev && ev.button === 2) openMenu(ev, { node: d.id, link: null });
1672 return;
1673 }
1674 // The one write. Everything before it was the pointer moving a box about;
1675 // this is what makes the picture reproduce.
1676 putPos(d.id, d.now.x, d.now.y);
1677 saveLayout();
1678 if (d.button === 2) swallowMenu(); else swallowClick();
1679 redraw();
1680 }
1681
1682 /// Eat the click the browser fires after the mouseup that ended a drag.
1683 ///
1684 /// Cleared on the next task rather than by the click itself: a release
1685 /// outside the picture fires no click at all, and a flag nothing clears would
1686 /// swallow the next real one instead.
1687 function swallowClick() {
1688 suppressClick = true;
1689 setTimeout(function () { suppressClick = false; }, 0);
1690 }
1691
1692 /// Eat the menu some browsers raise on the mouseup that ended a right drag,
1693 /// rather than on the mousedown that began it. Cleared on the next task, for
1694 /// the same reason [swallowClick] is.
1695 function swallowMenu() {
1696 suppressMenu = true;
1697 setTimeout(function () { suppressMenu = false; }, 0);
1698 }
1699
1700 /// Put the box back and forget the gesture. Escape during a drag lands here,
1701 /// so a drag begun by accident costs nothing -- and the lines go back with the
1702 /// box, since they followed it out.
1703 function abortDrag() {
1704 if (!drag) return false;
1705 var d = drag;
1706 moveTo(d.orig.x, d.orig.y);
1707 endDrag();
1708 if (d.button === 2) swallowMenu(); else swallowClick();
1709 return true;
1710 }
1711
1712 function endDrag() {
1713 if (drag && drag.g) drag.g.classList.remove('dragging');
1714 drag = null;
1715 document.removeEventListener('mousemove', onDragMove, true);
1716 document.removeEventListener('mouseup', onDragUp, true);
1717 document.removeEventListener('contextmenu', eatMenu, true);
1718 }
1719
1720 // ── Panning ────────────────────────────────────────────────
1721
1722 var pan = null; // { x, y, left, top }
1723
1724 function startPan(ev) {
1725 pan = { x: ev.clientX, y: ev.clientY, left: bodyEl.scrollLeft, top: bodyEl.scrollTop };
1726 bodyEl.classList.add('panning');
1727 document.addEventListener('mousemove', onPanMove, true);
1728 document.addEventListener('mouseup', onPanUp, true);
1729 }
1730
1731 function onPanMove(ev) {
1732 if (!pan) return;
1733 bodyEl.scrollLeft = pan.left - (ev.clientX - pan.x);
1734 bodyEl.scrollTop = pan.top - (ev.clientY - pan.y);
1735 ev.preventDefault();
1736 }
1737
1738 function onPanUp() {
1739 if (!pan) return;
1740 endPan();
1741 savePan();
1742 }
1743
1744 function endPan() {
1745 pan = null;
1746 if (bodyEl) bodyEl.classList.remove('panning');
1747 document.removeEventListener('mousemove', onPanMove, true);
1748 document.removeEventListener('mouseup', onPanUp, true);
1749 }
1750
1751 // ── The right-click menu ───────────────────────────────────
1752
1753 var menu = null;
1754
1755 function closeMenu() {
1756 if (!menu) return false;
1757 menu.remove();
1758 menu = null;
1759 return true;
1760 }
1761
1762 /// Put a floating thing inside the panel, clamped so it cannot be drawn
1763 /// outside the card -- the card clips, so anything past its edge is gone
1764 /// rather than merely awkward.
1765 function place(node, clientX, clientY) {
1766 var p = panelEl();
1767 if (!p) return;
1768 p.appendChild(node);
1769 var pr = p.getBoundingClientRect();
1770 var nr = node.getBoundingClientRect();
1771 var x = clientX - pr.left, y = clientY - pr.top;
1772 x = Math.max(4, Math.min(x, pr.width - nr.width - 4));
1773 y = Math.max(4, Math.min(y, pr.height - nr.height - 4));
1774 node.style.left = x + 'px';
1775 node.style.top = y + 'px';
1776 }
1777
1778 /// The menu the right button opens. What is on it depends on what was under
1779 /// the pointer, so a right-click on a link offers the link's own actions and
1780 /// one on empty space offers the picture's.
1781 function openMenu(ev, ctx) {
1782 closeMenu();
1783 closeEditor();
1784 menu = h('div', 'graph-menu');
1785 menu.id = 'graph-menu';
1786 menu.setAttribute('role', 'menu');
1787
1788 function item(label, fn, cls) {
1789 var b = h('button', 'graph-menu-item' + (cls ? ' ' + cls : ''), label);
1790 b.type = 'button';
1791 b.setAttribute('role', 'menuitem');
1792 b.addEventListener('click', function () { closeMenu(); fn(); });
1793 menu.appendChild(b);
1794 return b;
1795 }
1796 function sep() { menu.appendChild(h('div', 'graph-menu-sep')); }
1797
1798 if (ctx.node) {
1799 item(t('graph.menu_link'), function () {
1800 toggleLinkMode(true);
1801 link.from = ctx.node;
1802 paintToolbar(true);
1803 redraw();
1804 });
1805 item(t('graph.menu_open'), function () { select(ctx.node); });
1806 if (storedPos(ctx.node)) {
1807 item(t('graph.menu_reset_node'), function () {
1808 dropPos(ctx.node); saveLayout(); redraw();
1809 });
1810 }
1811 sep();
1812 }
1813 if (ctx.link) {
1814 item(t('graph.menu_edit_link'), function () {
1815 openEditor({ mode: 'edit', link: ctx.link, at: { x: ev.clientX, y: ev.clientY } });
1816 });
1817 item(t('graph.menu_drop_link'), function () { dropLink(ctx.link); }, 'danger');
1818 sep();
1819 }
1820 item(t('graph.organise'), organise);
1821 item(t('graph.menu_reset_all'), resetAll);
1822 item(t('graph.menu_reset_view'), resetView);
1823
1824 place(menu, ev.clientX, ev.clientY);
1825 }
1826
1827 // ── Editing a link ─────────────────────────────────────────
1828
1829 var editor = null;
1830
1831 function closeEditor() {
1832 if (!editor) return false;
1833 editor.remove();
1834 editor = null;
1835 return true;
1836 }
1837
1838 /// The link form: the relations, a note, and the two or three things that can
1839 /// be done with them.
1840 ///
1841 /// Deliberately small. The store's own words are that a relation is a word
1842 /// and a note is a sentence, and a form with more fields than the record has
1843 /// would be inviting the user to fill in something nothing reads.
1844 ///
1845 /// The relations are a SET, held in one field. There is one `rel` string in
1846 /// the record and there will go on being one; the comma between the words is
1847 /// what makes it several, and [relsOf] and [relsToStore] are the only two
1848 /// places that know it. The store allows thirty-two characters of relation in
1849 /// all, which is not many when they are shared out, so the form refuses a
1850 /// word that will not fit rather than letting the store quietly cut one in
1851 /// half.
1852 function openEditor(spec) {
1853 closeMenu();
1854 closeEditor();
1855 var isNew = spec.mode === 'new';
1856 var l = spec.link || null;
1857
1858 editor = h('div', 'graph-edit');
1859 editor.id = 'graph-edit';
1860 editor.setAttribute('role', 'dialog');
1861 editor.setAttribute('aria-label', isNew ? t('graph.new_title') : t('graph.edit_title'));
1862
1863 var from = isNew ? spec.from : l.from;
1864 var to = isNew ? spec.to : l.to;
1865 // The pair being linked, and the way out beside it. Cancel stays at the
1866 // foot: it is one half of a decision, not a dismissal, and it sits with
1867 // Save and Drop where the decisions are. The cross is what a hand reaches
1868 // for to leave a form alone, and on a phone this editor is 260px of a
1869 // 390px screen with the Graph panel filling the rest.
1870 var head = h('div', 'graph-edit-head', nameOf(from) + ' → ' + nameOf(to));
1871 if (window.DaimondCloser) {
1872 editor.appendChild(DaimondCloser.head('', {
1873 cls: 'graph-edit-top', titleEl: head,
1874 name: isNew ? t('graph.new_title') : t('graph.edit_title'),
1875 onClose: function () { closeEditor(); },
1876 }));
1877 } else {
1878 editor.appendChild(head);
1879 }
1880
1881 // The relations, as chips. A link used to carry one word in one field; it
1882 // carries a set now, and the set is edited the way a Diamond's tags are --
1883 // close a chip to drop it, click one from the pool to reuse it, type a
1884 // word that is not in the pool yet. The pulldown stays on the box, since
1885 // a word half typed is quicker completed than found among chips.
1886 var rels = l ? relsOf(l.rel) : [];
1887 var relLab = h('label', 'graph-edit-lab', t('graph.rels_label'));
1888 var chipRow = h('div', 'graph-rel-row');
1889 chipRow.id = 'graph-rel-chips';
1890 var rel = h('input', 'graph-edit-input');
1891 rel.type = 'text';
1892 rel.id = 'graph-edit-rel';
1893 rel.placeholder = t('graph.rel_add_ph');
1894 rel.setAttribute('list', 'graph-rel-list');
1895 rel.maxLength = REL_MAX;
1896 relLab.setAttribute('for', rel.id);
1897 var addBtn = h('button', 'graph-btn', '+');
1898 addBtn.type = 'button';
1899 addBtn.id = 'graph-rel-add';
1900 addBtn.title = t('graph.rel_add');
1901 var addRow = h('div', 'graph-rel-add');
1902 addRow.appendChild(rel);
1903 addRow.appendChild(addBtn);
1904 var poolLab = h('div', 'graph-rel-pool-lab', t('graph.rel_pool'));
1905 var poolRow = h('div', 'graph-rel-row graph-rel-pool');
1906 var hint = h('div', 'graph-rel-hint', '');
1907
1908 /// One chip, hued like the Diamonds' tags because it is the same kind of
1909 /// thing: a word the user chose, and will choose again.
1910 function chip(word, onclick) {
1911 var c = h(onclick ? 'button' : 'span', 'tag-chip');
1912 if (onclick) c.type = 'button';
1913 c.style.setProperty('--tag-h', hueOf(word));
1914 c.textContent = word;
1915 if (onclick) c.addEventListener('click', function () { onclick(word); });
1916 return c;
1917 }
1918
1919 /// Draw the link's own relations and what is left in the pool.
1920 function paintRels() {
1921 chipRow.textContent = '';
1922 if (!rels.length) chipRow.appendChild(h('span', 'graph-rel-none', t('graph.rel_none')));
1923 rels.forEach(function (word) {
1924 var c = chip(word, null);
1925 var x = h('button', 'tag-x', '×');
1926 x.type = 'button';
1927 x.title = t('graph.rel_remove', { rel: word });
1928 x.setAttribute('aria-label', t('graph.rel_remove', { rel: word }));
1929 x.addEventListener('click', function () {
1930 rels = rels.filter(function (u) { return u !== word; });
1931 hint.textContent = '';
1932 paintRels();
1933 });
1934 c.appendChild(x);
1935 chipRow.appendChild(c);
1936 });
1937 var pool = relsInUse().filter(function (w) { return rels.indexOf(w) === -1; });
1938 poolRow.textContent = '';
1939 pool.forEach(function (word) { poolRow.appendChild(chip(word, addRel)); });
1940 poolLab.style.display = pool.length ? '' : 'none';
1941 poolRow.style.display = pool.length ? '' : 'none';
1942 }
1943
1944 /// Take a typed or clicked word onto the link. False when it was refused
1945 /// for want of room, which is the one refusal that has to stop a save --
1946 /// the store would truncate, and a relation cut in half is a relation
1947 /// nobody wrote.
1948 function addRel(word) {
1949 var w = tidyRel(word);
1950 hint.textContent = '';
1951 if (!w) return true;
1952 if (rels.indexOf(w) !== -1) { rel.value = ''; paintRels(); return true; }
1953 if (relsToStore(rels.concat([w])).length > REL_MAX) {
1954 hint.textContent = t('graph.rel_full', { n: REL_MAX });
1955 return false;
1956 }
1957 rels.push(w);
1958 rel.value = '';
1959 paintRels();
1960 return true;
1961 }
1962
1963 rel.addEventListener('keydown', function (e) {
1964 // A comma is the separator the store uses, so typing one means the
1965 // same thing as pressing Enter rather than going into the word.
1966 if (e.key !== 'Enter' && e.key !== ',') return;
1967 e.preventDefault();
1968 addRel(rel.value);
1969 });
1970 addBtn.addEventListener('click', function () { addRel(rel.value); });
1971
1972 editor.appendChild(relLab);
1973 editor.appendChild(chipRow);
1974 editor.appendChild(addRow);
1975 editor.appendChild(relations());
1976 editor.appendChild(poolLab);
1977 editor.appendChild(poolRow);
1978 editor.appendChild(hint);
1979 paintRels();
1980
1981 var noteLab = h('label', 'graph-edit-lab', t('graph.note_label'));
1982 var note = h('textarea', 'graph-edit-note');
1983 note.id = 'graph-edit-note';
1984 note.rows = 2;
1985 note.placeholder = t('graph.note_ph');
1986 note.value = l ? (l.note || '') : '';
1987 noteLab.setAttribute('for', note.id);
1988 editor.appendChild(noteLab);
1989 editor.appendChild(note);
1990
1991
1992 var row = h('div', 'graph-edit-row');
1993 var ok = h('button', 'graph-btn primary', isNew ? t('graph.create') : t('graph.save'));
1994 ok.type = 'button';
1995 ok.id = 'graph-edit-ok';
1996 ok.addEventListener('click', function () {
1997 // A word typed and not added is a word the user meant, so it is taken
1998 // on the way out. If it will not fit, the form stays open saying so
1999 // rather than saving without it.
2000 if (!addRel(rel.value)) return;
2001 var r = relsToStore(rels), n = note.value;
2002 closeEditor();
2003 if (isNew) addLink(from, to, r, n);
2004 else replaceLink(l, r, n);
2005 });
2006 var cancel = h('button', 'graph-btn', t('graph.cancel'));
2007 cancel.type = 'button';
2008 cancel.id = 'graph-edit-cancel';
2009 cancel.addEventListener('click', function () { closeEditor(); });
2010 row.appendChild(ok);
2011 row.appendChild(cancel);
2012 if (!isNew) {
2013 var del = h('button', 'graph-btn danger', t('graph.drop'));
2014 del.type = 'button';
2015 del.id = 'graph-edit-delete';
2016 del.addEventListener('click', function () { closeEditor(); dropLink(l); });
2017 row.appendChild(del);
2018 }
2019 editor.appendChild(row);
2020
2021 var at = spec.at || anchorOf(l) || { x: 0, y: 0 };
2022 place(editor, at.x, at.y);
2023 rel.focus();
2024 }
2025
2026 /// Every relation the store already holds, once each and in the links' own
2027 /// order. Offering a word the store already holds is what keeps `part-of`
2028 /// from becoming three relations spelled three ways.
2029 function relsInUse() {
2030 var seen = {}, out = [];
2031 if (!lastStore) return out;
2032 lastStore.links.forEach(function (l) {
2033 relsOf(l.rel).forEach(function (r) {
2034 if (seen[r]) return;
2035 seen[r] = 1;
2036 out.push(r);
2037 });
2038 });
2039 return out;
2040 }
2041
2042 /// Those relations as a pulldown for the box they are typed into.
2043 function relations() {
2044 var list = h('datalist');
2045 list.id = 'graph-rel-list';
2046 relsInUse().forEach(function (r) {
2047 var o = document.createElement('option');
2048 o.value = r;
2049 list.appendChild(o);
2050 });
2051 return list;
2052 }
2053
2054 /// Where on screen a link's own line is, so the form opens beside the thing
2055 /// it is about.
2056 function anchorOf(l) {
2057 if (!l || !bodyEl) return null;
2058 var g = bodyEl.querySelector('g.graph-edge[data-link-id="' + cssq(l.id) + '"] path.graph-edge-line');
2059 if (!g || !g.getBoundingClientRect) return null;
2060 var r = g.getBoundingClientRect();
2061 return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
2062 }
2063
2064 function cssq(s) {
2065 return (window.CSS && CSS.escape) ? CSS.escape(s) : String(s).replace(/"/g, '\\"');
2066 }
2067
2068 // ── Writing ────────────────────────────────────────────────
2069
2070 /// Tell the rest of the app a link changed, exactly as its own link form
2071 /// does, so the Diamond's list and its artefact strip repaint too.
2072 function announce() {
2073 document.dispatchEvent(new CustomEvent('daimond-links-changed'));
2074 }
2075
2076 function addLink(from, to, rel, note) {
2077 return reader().then(function (a) {
2078 // The source Diamond owns the record. `all_links` walks the sidecars,
2079 // so which one holds it decides nothing about the picture -- but it
2080 // decides which Diamond is stamped, and the assertion belongs to the
2081 // end that made it.
2082 return a.add_link(from, 'diamond:' + from, 'diamond:' + to, rel || '', note || '', 'user');
2083 }).then(function () {
2084 announce();
2085 }).catch(function (e) {
2086 say(t('graph.write_failed', { err: (e && e.message) || String(e) }));
2087 });
2088 }
2089
2090 function dropLink(l) {
2091 return reader().then(function (a) {
2092 return a.remove_link(l.owner, l.id);
2093 }).then(function () {
2094 announce();
2095 }).catch(function (e) {
2096 say(t('graph.write_failed', { err: (e && e.message) || String(e) }));
2097 });
2098 }
2099
2100 /// Revise a link in place.
2101 ///
2102 /// This used to be a delete and a fresh assertion, because the store had no
2103 /// update — so changing the word on a line gave it a new id and a new `ts`,
2104 /// and the record of when the relationship was first asserted was lost. The
2105 /// dialog said so in a line of its own; `update_link` means it no longer has
2106 /// to. No equality check here either: `update_link` makes the same judgement,
2107 /// and makes it after normalising, so " INFORMS " over `informs` correctly
2108 /// writes nothing and does not stamp the Diamond for having done nothing.
2109 function replaceLink(l, rel, note) {
2110 return reader().then(function (a) {
2111 return a.update_link(l.owner, l.id, rel || '', note || '');
2112 }).then(function () {
2113 announce();
2114 }).catch(function (e) {
2115 say(t('graph.write_failed', { err: (e && e.message) || String(e) }));
2116 });
2117 }
2118
2119 // ── Wiring the picture ─────────────────────────────────────
2120
2121 var suppressClick = false;
2122 var suppressMenu = false;
2123
2124 /// The one place a pointer meets the picture. Re-attached on every draw,
2125 /// because the SVG is rebuilt on every draw.
2126 function wireCanvas(svg) {
2127 svg.addEventListener('mousedown', function (ev) {
2128 if (ev.button === 1) { // middle: pan
2129 ev.preventDefault();
2130 startPan(ev);
2131 return;
2132 }
2133 // The left button and the right both move a Diamond. The right was
2134 // asked for; the left stays because a click on a box has always opened
2135 // it and a press is how a click starts.
2136 if (ev.button !== 0 && ev.button !== 2) return;
2137 closeMenu();
2138 var g = ev.target.closest ? ev.target.closest('.graph-node') : null;
2139 // Not while linking: there the press is a pick, and a box that slid
2140 // under the pointer as the link was aimed would be a surprise.
2141 if (g && g.dataset.diamondId && !link.armed) {
2142 // Or the browser sweeps a text selection across every label the
2143 // pointer passes, which is what a press-and-move means to it.
2144 ev.preventDefault();
2145 startDrag(g, g.dataset.diamondId, ev, svg, ev.button);
2146 }
2147 });
2148
2149 svg.addEventListener('click', function (ev) {
2150 if (suppressClick) { suppressClick = false; return; }
2151 var node = ev.target.closest ? ev.target.closest('.graph-node') : null;
2152 var edge = ev.target.closest ? ev.target.closest('.graph-edge') : null;
2153 if (node && node.dataset.diamondId) {
2154 if (link.armed) linkClick(node.dataset.diamondId);
2155 else select(node.dataset.diamondId);
2156 return;
2157 }
2158 if (edge && edge.dataset.linkId) {
2159 var l = linkById(edge.dataset.linkId);
2160 if (l) openEditor({ mode: 'edit', link: l, at: { x: ev.clientX, y: ev.clientY } });
2161 return;
2162 }
2163 // Empty space. The way out of link mode a pointer already knows, and
2164 // the way out of anything else standing open.
2165 if (cancelLink()) return;
2166 closeEditor();
2167 });
2168
2169 svg.addEventListener('mousemove', function (ev) {
2170 if (!link.from) return;
2171 link.at = atPoint(svg, ev);
2172 drawLive(link.at);
2173 });
2174
2175 svg.addEventListener('contextmenu', function (ev) {
2176 ev.preventDefault();
2177 // The right button is holding a Diamond, or has just let one go after
2178 // moving it. Either way this is not a request for a menu: a press on a
2179 // box decides between the two at the mouseup, in [onDragUp].
2180 if (drag || suppressMenu) return;
2181 var node = ev.target.closest ? ev.target.closest('.graph-node') : null;
2182 var edge = ev.target.closest ? ev.target.closest('.graph-edge') : null;
2183 openMenu(ev, {
2184 node: node ? node.dataset.diamondId : null,
2185 link: edge ? linkById(edge.dataset.linkId) : null,
2186 });
2187 });
2188
2189 // Chrome's autoscroll would otherwise take the middle button off us.
2190 svg.addEventListener('auxclick', function (ev) { if (ev.button === 1) ev.preventDefault(); });
2191 bodyEl.addEventListener('scroll', onScroll);
2192 }
2193
2194 var scrollTimer = null;
2195 /// Remember the view after the scrolling stops, not on every pixel of it.
2196 function onScroll() {
2197 if (scrollTimer) clearTimeout(scrollTimer);
2198 scrollTimer = setTimeout(function () { scrollTimer = null; savePan(); }, 250);
2199 }
2200
2201 function linkById(id) {
2202 if (!lastStore || !id) return null;
2203 var hit = lastStore.links.filter(function (l) { return l.id === id; })[0];
2204 if (!hit) return null;
2205 return {
2206 id: hit.id, owner: hit.owner, rel: hit.rel || '', note: hit.note || '',
2207 from: diamondOf(hit.from), to: diamondOf(hit.to),
2208 };
2209 }
2210
2211 /// Everything that is open, shut, in the order a user means when they press
2212 /// Escape: the innermost thing first.
2213 ///
2214 /// One function and one key handler, on the document rather than on the
2215 /// panel, because a handler that only fires while the focus is inside the
2216 /// thing stops working the moment somebody clicks the words they are reading.
2217 function dismiss() {
2218 if (closeEditor()) return true;
2219 if (closeMenu()) return true;
2220 if (abortDrag()) return true;
2221 if (cancelLink()) return true;
2222 return false;
2223 }
2224
2225 // ── Refreshing ─────────────────────────────────────────────
2226
2227 function panelEl() { return document.getElementById('panel-graph'); }
2228
2229 function visible() {
2230 var p = panelEl();
2231 return !!(p && !p.classList.contains('closed') && p.offsetParent !== null);
2232 }
2233
2234 /// Whether there is an account open to draw the store of.
2235 ///
2236 /// Until the gate is passed the OPFS namespace is not the signed-in one, so a
2237 /// draw made then would show an EMPTY store -- and would look exactly like a
2238 /// workspace with nothing in it. Better to draw nothing and wait.
2239 function unlocked() {
2240 var m = document.getElementById('identity-modal');
2241 return !m || m.style.display === 'none';
2242 }
2243
2244 /// Draw again from what was last read, for a change that is the layout's
2245 /// alone -- a drag, an organise, arming the mode. Going back to OPFS for it
2246 /// would be a read the store has nothing new to answer.
2247 function redraw() {
2248 if (!bodyEl || !lastStore) return refresh();
2249 render(lastStore);
2250 }
2251
2252 /// Redraw from the store. Safe to call before init, and safe to call twice:
2253 /// a request arriving mid-draw is folded into one more draw at the end.
2254 function refresh() {
2255 bodyEl = document.getElementById('graph-body');
2256 if (!bodyEl) return Promise.resolve();
2257 if (!unlocked()) return Promise.resolve();
2258 // A redraw under a gesture would pull the box out from under the pointer.
2259 if (drag || pan) { again = true; return Promise.resolve(); }
2260 if (drawing) { again = true; return Promise.resolve(); }
2261 drawing = true;
2262 closeMenu();
2263 return load().then(function (store) {
2264 render(store);
2265 }).catch(function (e) {
2266 // A failed read must not leave the last picture standing, or it
2267 // reads as the current one.
2268 app = null;
2269 lastStore = null;
2270 bodyEl.textContent = '';
2271 var p = document.createElement('p');
2272 p.className = 'graph-empty';
2273 p.textContent = t('graph.failed', { err: (e && e.message) || String(e) });
2274 bodyEl.appendChild(p);
2275 }).then(function () {
2276 drawing = false;
2277 if (again) { again = false; return refresh(); }
2278 });
2279 }
2280
2281 /// Redraw only when the pane is on screen, which is every trigger below
2282 /// except being opened.
2283 function refreshIfVisible() {
2284 if (visible()) refresh();
2285 }
2286
2287 function init() {
2288 bodyEl = document.getElementById('graph-body');
2289 if (!bodyEl) return;
2290
2291 // The other half of the link UI says when a link changed.
2292 document.addEventListener('daimond-links-changed', refreshIfVisible);
2293 // TWO names, and both are live -- please do not fold them into one. A
2294 // Diamond changing is a broad signal that merely MIGHT have moved a colour
2295 // (dispatched at `daimond.js:25139`); the colour picker knows perfectly well
2296 // that it did (`daimond.js:10427`). Neither is only about colour, so both go
2297 // through the compare in [refreshColours] rather than straight to a redraw,
2298 // and a picture that repaints only when something else happens to redraw it
2299 // is the kind of fault nobody notices until they are demonstrating.
2300 document.addEventListener('daimond-diamond-changed', refreshColours);
2301 document.addEventListener('daimond-tile-colour-changed', refreshColours);
2302 // A THIRD name, `daimond-tile-prefs-changed`, was listened for here and was
2303 // dispatched by nothing in `www/`, `src/`, `ext/` or `dev/`. It went on
2304 // 2026-08-15. It defended nothing -- an event nobody fires cannot repaint a
2305 // picture -- and it read as though a producer existed, which two reachability
2306 // passes duly reported as a route. Whoever writes that producer should add
2307 // the line back in the same commit, where it costs one line and is true.
2308 // Another tab's Diamond mutation. OPFS fires nothing across tabs; this
2309 // nonce in localStorage is the only signal there is.
2310 window.addEventListener('storage', function (e) {
2311 if (e.key === 'daimond-diamonds-rev') refreshIfVisible();
2312 // Another tab moved a box. The shim in accounts.js prefixes the key
2313 // per account, so it is the SUFFIX that identifies it -- and only the
2314 // current account's tab is looking at the current account's picture.
2315 if (e.key && e.key.length >= LAYOUT_KEY.length
2316 && e.key.slice(-LAYOUT_KEY.length) === LAYOUT_KEY) {
2317 layout = null;
2318 refreshIfVisible();
2319 }
2320 // And another tab gave a Diamond a colour, which the same suffix rule
2321 // finds for the same reason.
2322 if (e.key && e.key.length >= TILE_KEY.length
2323 && e.key.slice(-TILE_KEY.length) === TILE_KEY) refreshColours();
2324 });
2325 // Escape, from anywhere. Capture, so a form field inside the editor
2326 // cannot swallow it first.
2327 document.addEventListener('keydown', function (e) {
2328 if (e.key !== 'Escape') return;
2329 if (!visible() && !menu && !editor) return;
2330 if (dismiss()) { e.stopPropagation(); e.preventDefault(); }
2331 }, true);
2332 // A click anywhere else shuts the menu, which is how every menu behaves.
2333 document.addEventListener('mousedown', function (e) {
2334 if (menu && !menu.contains(e.target)) closeMenu();
2335 if (editor && !editor.contains(e.target)
2336 && !(e.target.closest && e.target.closest('.graph-edge'))) closeEditor();
2337 }, true);
2338 // And when the panel is opened, since it is drawn on being shown rather
2339 // than kept up to date while nobody is looking at it.
2340 var p = panelEl();
2341 if (p) {
2342 var was = visible();
2343 new MutationObserver(function () {
2344 var now = visible();
2345 if (now && !was) refresh();
2346 if (!now && was) { closeMenu(); closeEditor(); cancelLink(); }
2347 was = now;
2348 }).observe(p, { attributes: true, attributeFilter: ['class', 'style'] });
2349 }
2350 // And when the gate comes down. A saved layout can have this panel
2351 // already open at the moment the page loads, in which case it never
2352 // transitions from hidden to shown and the observer above never fires --
2353 // so signing in has to be a trigger in its own right. It is also the
2354 // trigger for a SWITCH of account, which changes the whole store, and
2355 // therefore the whole arrangement.
2356 var gate = document.getElementById('identity-modal');
2357 if (gate) {
2358 var wasLocked = !unlocked();
2359 new MutationObserver(function () {
2360 var lockedNow = !unlocked();
2361 if (wasLocked && !lockedNow) { layout = null; refreshIfVisible(); }
2362 wasLocked = lockedNow;
2363 }).observe(gate, { attributes: true, attributeFilter: ['class', 'style'] });
2364 }
2365 if (window.DaimondI18n) DaimondI18n.onChange(function () {
2366 paintToolbar(!!(lastStore && lastStore.diamonds.length));
2367 refreshIfVisible();
2368 });
2369 if (visible()) refresh();
2370 }
2371
2372 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
2373 else init();
2374
2375 window.DaimondGraph = {
2376 /// Redraw from the store. Safe to call before init.
2377 refresh: refresh,
2378 /// Lay the Diamonds out again and keep where the layout put them.
2379 organise: organise,
2380 /// Arm or disarm link mode; with no argument, toggle it.
2381 linkMode: toggleLinkMode,
2382 /// Scale the picture until every Diamond is on screen.
2383 fitAll: fitAll,
2384 /// Full size, at the origin.
2385 resetView: resetView,
2386 /// Shut whatever is open, innermost first. Returns whether anything was.
2387 escape: dismiss,
2388 /// The stored arrangement, for the sync parcel. Positions only: the pan is
2389 /// this window's, not this account's.
2390 snapshot: snapshot,
2391 /// Take an arrangement from the sync, merged per Diamond by its stamp.
2392 adopt: adopt,
2393 /// What is on screen, for a verifier: where every box is and what the
2394 /// store said. Never used by the app itself.
2395 _geometry: function () { return lastGeo; },
2396 /// How a link's `rel` becomes several relations, and back.
2397 ///
2398 /// Published because the Diamond panel draws the same link on its own
2399 /// surface and has to agree with this one about where a relation ends. The
2400 /// comma is a store convention, not a picture's: two readings of it would
2401 /// be two answers to "how many relations does this link carry", and the
2402 /// module that already carries a hand copy of `tagHue` is not the place to
2403 /// start a second such pair.
2404 rels: {
2405 of: relsOf,
2406 toStore: relsToStore,
2407 tidy: tidyRel,
2408 /// Every relation the store already holds, so a second surface can offer
2409 /// the words this one does. Empty until the picture has been drawn once.
2410 inUse: relsInUse,
2411 /// The store's cap, in characters of the joined string.
2412 MAX: REL_MAX,
2413 },
2414 };
2415})();