-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathviewer.js
More file actions
1520 lines (1448 loc) · 72.3 KB
/
Copy pathviewer.js
File metadata and controls
1520 lines (1448 loc) · 72.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* Runtime for generated pages (copied into the output as app.js).
Every diagram ships as an already-laid-out graphviz SVG, computed at
build time (index.mjs) — this file only adds interactivity on top:
- pan/zoom/maximize on the pre-rendered SVG;
- hover highlighting: a node lights up together with every edge from/to it
and its direct neighbors, everything else fades;
- a tooltip near the cursor fed from graph-data.js (window.GRAPH);
- per-diagram engine switching (dot/neato/fdp) and a variables toggle,
both instant since every engine's SVG is already pre-built. */
(function () {
'use strict';
// Chrome disables Web Storage for file:// pages by default (this tool's
// primary way of being opened — "open index.html in a browser", no server
// needed), so localStorage.setItem throws there. A dropdown's change
// handler that does `localStorage.setItem(...); onChange(...)` silently
// never reaches onChange when that throws — the setting just looks inert.
// Falling back to an in-memory Map keeps the current tab's picks working
// even when nothing can persist across reloads.
const memoryStorage = new Map();
function storageGet(key) {
try { return localStorage.getItem(key); } catch (e) { return memoryStorage.get(key) ?? null; }
}
function storageSet(key, value) {
try { localStorage.setItem(key, value); } catch (e) { memoryStorage.set(key, String(value)); }
}
// Position within a diagram is tracked as an explicit translate + scale on
// .inner rather than via native scrollLeft/scrollTop: scrollLeft/scrollTop
// can never go negative, so centering content that's smaller than the
// viewport (e.g. a tall, narrow graph scaled down to fit) used to clamp to
// 0 and leave it flush against the left/top edge instead of centered.
// A translate has no such floor, so centering and panning both just work.
function getTransform(inner) {
return {
z: parseFloat(inner.dataset.z || '1'),
tx: parseFloat(inner.dataset.tx || '0'),
ty: parseFloat(inner.dataset.ty || '0'),
};
}
function setTransform(inner, z, tx, ty) {
inner.dataset.z = z;
inner.dataset.tx = tx;
inner.dataset.ty = ty;
inner.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + z + ')';
}
// Zoom the diagram so that the container point (cx, cy) stays fixed:
// convert it to content coordinates at the old scale, rescale, then
// translate so the same content point is back under (cx, cy).
function applyZoom(diagram, factor, cx, cy) {
const inner = diagram.querySelector('.inner');
if (!inner) return;
const { z, tx, ty } = getTransform(inner);
const z2 = Math.min(8, Math.max(0.08, z * factor));
if (z2 === z) return;
const ox = inner.offsetLeft, oy = inner.offsetTop; // zoombar sits above .inner
const px = (cx - ox - tx) / z;
const py = (cy - oy - ty) / z;
setTransform(inner, z2, cx - ox - px * z2, cy - oy - py * z2);
}
window.zoom = function (btn, factor) {
const d = btn.closest('.diagram');
applyZoom(d, factor, d.clientWidth / 2, d.clientHeight / 2);
};
const DRAG_THRESHOLD = 4; // px of movement before a left-button press becomes a pan
// Wheel over a diagram zooms at the cursor (the page scrolls only when the
// cursor is outside). Left-button press-and-drag on empty background pans;
// starting on a node/edge/zoombar is left alone for their own click handlers.
function setupPanZoom(diagram) {
diagram.addEventListener('wheel', ev => {
ev.preventDefault();
const delta = ev.deltaMode === 1 ? ev.deltaY * 33 : ev.deltaY; // lines -> px
const rect = diagram.getBoundingClientRect();
applyZoom(diagram, Math.pow(1.0016, -delta), ev.clientX - rect.left, ev.clientY - rect.top);
}, { passive: false });
let drag = null;
diagram.addEventListener('pointerdown', ev => {
if (ev.button !== 0) return;
if (ev.target.closest('g.node, g.edge, .zoombar')) return;
// stop the browser's native text/element drag-selection from starting —
// otherwise a press-drag that happens to start over an SVG text node
// paints a selection instead of (or as well as) panning
ev.preventDefault();
// preventDefault() above suppresses the compatibility mousedown event
// this pointerdown would otherwise trigger, so the mousedown-based
// focus listener below never fires for clicks on empty background —
// focus here directly so F/Home/Escape still target this diagram
diagram.focus({ preventScroll: true });
const inner = diagram.querySelector('.inner');
const { tx, ty } = getTransform(inner);
drag = { x: ev.clientX, y: ev.clientY, tx, ty, moved: false, id: ev.pointerId };
});
diagram.addEventListener('pointermove', ev => {
if (!drag) return;
const dx = ev.clientX - drag.x, dy = ev.clientY - drag.y;
if (!drag.moved) {
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
drag.moved = true;
diagram.setPointerCapture(drag.id);
diagram.classList.add('panning');
}
const inner = diagram.querySelector('.inner');
const { z } = getTransform(inner);
setTransform(inner, z, drag.tx + dx, drag.ty + dy);
});
const endDrag = () => {
if (!drag) return;
if (drag.moved) {
diagram.classList.remove('panning');
if (diagram.hasPointerCapture && diagram.hasPointerCapture(drag.id)) {
diagram.releasePointerCapture(drag.id);
}
// the drag ends with a click event on whatever's under the cursor;
// swallow just that one so it doesn't also lock/unlock a highlight
const swallow = ev2 => { ev2.stopPropagation(); diagram.removeEventListener('click', swallow, true); };
diagram.addEventListener('click', swallow, true);
}
drag = null;
};
diagram.addEventListener('pointerup', endDrag);
diagram.addEventListener('pointercancel', endDrag);
// belt-and-braces: some browsers fire a native dragstart for SVG content
// even with user-select:none and preventDefault() on pointerdown
diagram.addEventListener('dragstart', ev => ev.preventDefault());
// focus follows any interaction with this diagram (click on a node,
// drag-pan, or the maximize button) so the F/Home/Escape shortcuts below
// know which diagram they apply to
diagram.addEventListener('mousedown', () => diagram.focus({ preventScroll: true }));
}
// --- maximize to the full browser window (not the Fullscreen API — the
// tab strip and everything above it stays visible) + Home-to-fit-selection ---
function setMaximized(diagram, on) {
diagram.classList.toggle('maximized', on);
document.body.classList.toggle('diagram-maximized', on);
const btn = diagram.parentElement.querySelector('.maxbtn');
if (btn) {
btn.classList.toggle('active', on);
btn.title = on ? 'Свернуть (Esc)' : 'На весь экран (F)';
}
}
// bounding box (in unscaled .inner content coordinates) enclosing a set of
// elements, found via their on-screen rects so nested SVG/HTML transforms
// never need to be untangled by hand
function contentBox(diagram, els) {
const inner = diagram.querySelector('.inner');
if (!inner) return null;
const innerRect = inner.getBoundingClientRect();
const z = parseFloat(inner.dataset.z || '1');
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const el of els) {
const r = el.getBoundingClientRect();
if (!r.width && !r.height) continue;
x1 = Math.min(x1, (r.left - innerRect.left) / z);
y1 = Math.min(y1, (r.top - innerRect.top) / z);
x2 = Math.max(x2, (r.right - innerRect.left) / z);
y2 = Math.max(y2, (r.bottom - innerRect.top) / z);
}
if (!isFinite(x1)) return null;
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
}
// the currently pinned/hovered selection (nodes+edges carrying .hl), or
// null if nothing is highlighted right now
function selectionBox(diagram) {
const svg = diagram.querySelector('svg');
if (!svg) return null;
const els = svg.querySelectorAll('.hl');
return els.length ? contentBox(diagram, els) : null;
}
function wholeBox(diagram) {
const svg = diagram.querySelector('svg');
return svg ? contentBox(diagram, [svg]) : null;
}
function fitToView(diagram, box) {
const inner = diagram.querySelector('.inner');
if (!inner || !box || box.w <= 0 || box.h <= 0) return;
const margin = 0.92; // small breathing room around the fitted box
const z2 = Math.min(8, Math.max(0.08,
Math.min(diagram.clientWidth / box.w, diagram.clientHeight / box.h) * margin));
const ox = inner.offsetLeft, oy = inner.offsetTop;
const tx = diagram.clientWidth / 2 - ox - (box.x + box.w / 2) * z2;
const ty = diagram.clientHeight / 2 - oy - (box.y + box.h / 2) * z2;
setTransform(inner, z2, tx, ty);
}
// Home: fit the current selection if there is one, else fit everything
function homeFit(diagram) {
fitToView(diagram, selectionBox(diagram) || wholeBox(diagram));
}
window.toggleMaximize = function (btn) {
// btn's own parent is .diagram-toolbar for a graphviz diagram (or
// .diagram-wrap directly for a plain one) — .diagram-wrap is the
// reliable common ancestor to search from either way
const diagram = btn.closest('.diagram-wrap').querySelector('.diagram');
setMaximized(diagram, !diagram.classList.contains('maximized'));
homeFit(diagram);
// the button lives outside .diagram, so a native click doesn't leave
// focus on it the way clicking inside the diagram would — force it so
// Escape/F/Home immediately recognize this diagram as active
diagram.focus({ preventScroll: true });
};
// F/Home/Escape only act on a diagram that was clicked into or otherwise
// holds focus, so typing elsewhere on the page never triggers them
document.addEventListener('keydown', ev => {
const active = document.activeElement;
const diagram = active && active.closest && active.closest('.diagram');
if (!diagram) return;
if (ev.key === 'Escape') {
if (diagram.classList.contains('maximized')) { ev.preventDefault(); setMaximized(diagram, false); }
} else if (ev.key === 'f' || ev.key === 'F') {
ev.preventDefault();
setMaximized(diagram, !diagram.classList.contains('maximized'));
homeFit(diagram);
} else if (ev.key === 'Home') {
ev.preventDefault();
homeFit(diagram);
}
});
const KIND_LABEL = {
fn: 'функция', entry: 'точка входа', isr: 'обработчик прерывания',
gvar: 'глобальная переменная', gvolatile: 'volatile-глобальная',
extfn: 'внешняя функция / макрос', extvar: 'внешняя переменная', file: 'файл',
periph: 'периферия (регистры)',
};
// PAGE_EXTRA_NODES holds page-local synthetic nodes (e.g. level 0's
// variable barrels, bnd_0/bnd_1/...) that aren't real entities and so were
// never worth putting in the shared graph-data.js — same idea as
// CFG_LINKS below, just for tooltip info instead of a href.
function nodes() {
return { ...(window.GRAPH && window.GRAPH.nodes), ...window.PAGE_EXTRA_NODES };
}
// Node ids known on the current page: graph-data.js entries (functions,
// globals, files — shared across all pages, hrefs stored root-relative)
// plus this page's own CFG-diagram call-node links, if any (ids like "c3"
// are only unique within one function's flowchart, so they can't live in
// the shared graph-data.js — each function page gets its own CFG_LINKS).
function knownKeys() {
const s = new Set(Object.keys(nodes()));
if (window.CFG_LINKS) for (const k of Object.keys(window.CFG_LINKS)) s.add(k);
return s;
}
function hrefFor(key) {
const info = nodes()[key];
const href = (info && info.href) || (window.CFG_LINKS && window.CFG_LINKS[key]);
return href ? (window.PAGE_REL || '') + href : null;
}
// also escapes '>' (not just '&'/'<'): needed wherever this feeds a
// graphviz HTML-like label=<...> block (setupRelationsDiagram) — a raw
// '->' in a function's doc-comment description (common in this codebase)
// otherwise corrupts the label's own delimiters and silently breaks that
// render. Matches index.mjs's server-side dotEsc, which already does this.
const escHtml = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
const tip = document.createElement('div');
tip.className = 'tip';
function tipHtml(info) {
const esc = escHtml;
let h = '<div class="k">' + (KIND_LABEL[info.kind] || info.kind) + (info.file ? ' · ' + esc(info.file) : '') + '</div>';
h += '<b>' + esc(info.label) + '</b>';
if (info.sig) h += '<div class="sig">' + esc(info.sig) + '</div>';
if (info.type) {
h += '<div class="sig">' + esc(info.type) +
(info.static ? ' · static' : '') + (info.volatile ? ' · volatile' : '') + '</div>';
}
if (info.desc) h += '<div class="d">' + esc(info.desc) + '</div>';
if (info.users) h += '<div class="k">используют: ' + info.users + ' функц.</div>';
if (info.writers && info.writers.length) h += '<div class="k">пишут: ' + esc(info.writers.join(', ')) + '</div>';
if (info.readers && info.readers.length) h += '<div class="k">читают: ' + esc(info.readers.join(', ')) + '</div>';
if (info.armers && info.armers.length) h += '<div class="k">взводят прерывание: ' + esc(info.armers.join(', ')) + '</div>';
if (info.isrTargets && info.isrTargets.length) h += '<div class="k">вызывает обработчики: ' + esc(info.isrTargets.join(', ')) + '</div>';
return h;
}
// Which side of the cursor the tip grows toward: *behind* the direction
// the cursor is travelling in — trailing where it came from rather than
// sitting ahead of it, which otherwise put the tip directly in the way of
// continued movement in that direction. lastMouseX/Y and tipDirX/Y persist
// across calls (module-level, not per-diagram) so direction survives
// between events; TIP_DIR_THRESHOLD keeps a stray pixel of hand tremor
// between two mousemove events from flipping the side back and forth —
// direction only updates once actual travel since the last call clears
// that threshold, otherwise it keeps the last known direction (sticky
// through a momentary pause, and a sane default of down-right/tip-up-left
// before any movement has ever been recorded this page load).
let lastMouseX = null, lastMouseY = null;
let tipDirX = 1, tipDirY = 1;
const TIP_DIR_THRESHOLD = 6;
function moveTip(ev) {
const pad = 14;
const r = tip.getBoundingClientRect();
if (lastMouseX !== null) {
const dx = ev.clientX - lastMouseX, dy = ev.clientY - lastMouseY;
if (Math.abs(dx) >= TIP_DIR_THRESHOLD) tipDirX = dx > 0 ? 1 : -1;
if (Math.abs(dy) >= TIP_DIR_THRESHOLD) tipDirY = dy > 0 ? 1 : -1;
}
lastMouseX = ev.clientX;
lastMouseY = ev.clientY;
let x = tipDirX > 0 ? ev.clientX - r.width - pad : ev.clientX + pad;
let y = tipDirY > 0 ? ev.clientY - r.height - pad : ev.clientY + pad;
// still clamp inside the viewport — the travel direction can still land
// the tip off-screen near the window's own edge
x = Math.max(8, Math.min(x, window.innerWidth - r.width - 8));
y = Math.max(8, Math.min(y, window.innerHeight - r.height - 8));
tip.style.left = x + 'px';
tip.style.top = y + 'px';
}
// hoisted out of setupGraphvizSvg so setupRelationsDiagram (a different
// node/edge wiring for the same tooltip) can share it instead of
// duplicating the info-lookup + render logic
function hideTip() { tip.style.display = 'none'; }
function showTip(key, ev) {
const info = nodes()[key];
if (!info) { hideTip(); return; }
tip.innerHTML = tipHtml(info);
tip.style.display = 'block';
moveTip(ev);
}
// Every diagram ships as an already-laid-out graphviz SVG (built at build
// time, not rendered in the browser), so node/edge discovery is simple:
// our own ids are already the SVG ids, and edges carry their endpoints in
// a <title>from->to</title>.
function setupGraphvizSvg(svg) {
const known = knownKeys();
const nodeEls = new Map();
svg.querySelectorAll('g.node[id]').forEach(el => {
if (known.has(el.id)) nodeEls.set(el.id, [el]);
});
const edges = [];
svg.querySelectorAll('g.edge').forEach(el => {
const title = el.querySelector('title');
const parts = title ? title.textContent.split('->') : null;
if (parts && parts.length === 2 && known.has(parts[0]) && known.has(parts[1])) {
edges.push({ el, from: parts[0], to: parts[1] });
}
});
let locked = null;
function clearHighlight() {
svg.classList.remove('fade');
svg.querySelectorAll('.hl').forEach(el => el.classList.remove('hl'));
}
function highlightNode(key) {
svg.classList.add('fade');
const on = new Set([key]);
for (const e of edges) {
if (e.from === key || e.to === key) {
e.el.classList.add('hl');
on.add(e.from);
on.add(e.to);
}
}
for (const k of on) (nodeEls.get(k) || []).forEach(el => el.classList.add('hl'));
}
function highlightEdge(e) {
svg.classList.add('fade');
e.el.classList.add('hl');
[e.from, e.to].forEach(k => (nodeEls.get(k) || []).forEach(el => el.classList.add('hl')));
}
for (const [key, els] of nodeEls) {
for (const el of els) {
el.addEventListener('mouseenter', ev => {
showTip(key, ev);
if (locked) return;
clearHighlight();
highlightNode(key);
});
el.addEventListener('mousemove', ev => {
if (tip.style.display !== 'none') moveTip(ev);
});
el.addEventListener('mouseleave', () => {
hideTip();
if (!locked) clearHighlight();
});
el.addEventListener('click', ev => {
ev.stopPropagation();
if (locked === key) { locked = null; clearHighlight(); return; }
locked = key;
clearHighlight();
highlightNode(key);
});
el.addEventListener('dblclick', ev => {
ev.stopPropagation();
const href = hrefFor(key);
if (href) window.location.href = href;
});
}
}
for (const e of edges) {
const edgeKey = 'edge:' + e.from + '>' + e.to;
e.el.addEventListener('mouseenter', () => {
hideTip();
if (locked) return;
clearHighlight();
highlightEdge(e);
});
e.el.addEventListener('mouseleave', () => { if (!locked) clearHighlight(); });
e.el.addEventListener('click', ev => {
ev.stopPropagation();
if (locked === edgeKey) { locked = null; clearHighlight(); return; }
locked = edgeKey;
clearHighlight();
highlightEdge(e);
});
}
svg.addEventListener('click', () => { if (locked) { locked = null; clearHighlight(); } });
}
// --- function-page "Связи": click a caller/callee to walk the call chain ---
//
// Unlike every other diagram, this one isn't fixed at build time: the base
// 1-hop view (focus + its direct callers/callees, already baked into the
// page) can be extended arbitrarily deep by clicking a node, one hop at a
// time, independently on the caller side and the callee side. Doing that
// against a diagram that already contains every hidden node (laid out once,
// up front, with space reserved for all of it) would route edges around
// holes for nodes the user never asked to see — so instead every click
// rebuilds a dot subgraph containing only what should currently be visible
// and re-lays it out with graphviz-wasm.js (lazy-loaded on first use),
// giving a clean layout every time instead of a static one with gaps.
let gvPromise = null;
function loadGraphvizWasm() {
if (gvPromise) return gvPromise;
gvPromise = new Promise((resolve, reject) => {
if (window.GraphvizWasm) { resolve(window.GraphvizWasm); return; }
const s = document.createElement('script');
s.src = (window.PAGE_REL || '') + 'graphviz-wasm.js';
s.onload = () => resolve(window.GraphvizWasm);
s.onerror = () => reject(new Error('graphviz-wasm.js failed to load'));
document.head.appendChild(s);
}).then(w => w.Graphviz.load());
return gvPromise;
}
const relTrunc = (s, n) => (s.length > n ? s.slice(0, n - 1).replace(/\s+\S*$/, '') + '…' : s);
const RELKIND = { isr: 'ISR', entry: 'main', fn: 'func' };
function relFnNodeLine(id, info, sameFile, pos = '') {
const rows = [`<FONT POINT-SIZE="10">${escHtml(RELKIND[info.kind] || 'func')}</FONT>`,
`<B>${escHtml(info.label)}</B>`];
if (info.file && !sameFile) rows.push(`<FONT POINT-SIZE="9">${escHtml(info.file)}</FONT>`);
if (info.desc) rows.push(`<FONT POINT-SIZE="9"><I>${escHtml(relTrunc(info.desc, 46))}</I></FONT>`);
return ` ${id} [id="${id}"${pos} class="${info.kind}" shape=box label=<${rows.join('<BR/>')}>];`;
}
function relVarNodeLine(id, info, sameFile, pos = '') {
const kindLabel = info.kind === 'extvar' ? 'ext var' : info.kind === 'gvolatile' ? 'volatile' : 'var';
const cls = info.kind === 'extvar' ? 'ghost' : 'gvar';
const nameColor = info.kind === 'gvolatile' ? ' COLOR="#dc2626"' : '';
const rows = [`<FONT POINT-SIZE="10">${escHtml(kindLabel)}</FONT>`, `<B${nameColor}>${escHtml(info.label)}</B>`];
const sub = [];
if (info.type) sub.push(escHtml(info.type));
if (info.static) sub.push('static');
if (info.file && !sameFile) sub.push(escHtml(info.file));
if (sub.length) rows.push(`<FONT POINT-SIZE="9">${sub.join(' · ')}</FONT>`);
return ` ${id} [id="${id}"${pos} class="${cls}" shape=cylinder label=<${rows.join('<BR/>')}>];`;
}
// peripheral block on a function's "Связи" diagram — kept compact (just the
// name); the specific registers/bits this function touches go on the edge
// instead (see relPeriphDirDetail), mirroring the build-time
// dotPeriphRelNode + periphDirDetail split so a client re-layout looks
// identical.
function relPeriphNodeLine(id, name, pos = '') {
const rows = [`<FONT POINT-SIZE="10">периферия</FONT>`, `<B>${escHtml(name)}</B>`];
return ` ${id} [id="${id}"${pos} class="periph" shape=hexagon label=<${rows.join('<BR/>')}>];`;
}
// mirrors index.mjs's isEnableFlagName/shortFlagName exactly (see there for
// the rationale) — two separate runtimes (this file ships to the browser,
// index.mjs runs at build time), so the logic is duplicated rather than
// shared.
const ENABLE_FLAG_RE = /(?:EN|ON|UE)$/;
function isEnableFlagName(name) { return ENABLE_FLAG_RE.test(name); }
// drops only the leading family segment (`DMA_CCR_EN` -> `CCR_EN`), keeps
// the register — same rule for every peripheral, no RCC special case.
function shortFlagName(flagName) {
const idx = flagName.indexOf('_');
return idx === -1 ? flagName : flagName.slice(idx + 1);
}
// one direction's full register/bit breakdown — mirrors index.mjs's
// periphDirDetail. No longer the edge's *visible* label (see addPeriphOf):
// a long version of it used to be baked straight into the graphviz label,
// and neato has no box to fit a tall multi-line label against, so it would
// drift the label away from the edge once a function touched enough
// registers at once. Returned separately and revealed only on hover — see
// injectPeriphDetailLabels and the .periph-detail/.periph-default CSS pair.
function relPeriphDirDetail(regs, dir, cap = 6) {
const relevant = regs.filter(r => r.mode.includes(dir));
if (!relevant.length) return { detail: '', hasEnable: false, enableLabel: '' };
let hasEnable = false, enableLabel = '';
const sorted = [...relevant].sort((a, b) => a.reg.localeCompare(b.reg));
const names = sorted.map(r => {
if (dir === 'w') {
// r.wFlags is [name, 'set'|'clear'|'both'][] (index.mjs's periphFlags
// 'w' side is a Map now, keyed by set/clear polarity — see
// mergeFlagPolarity there) — mirrors periphDirDetail exactly: a bit
// only ever cleared here gets a ~ prefix and never counts as the
// edge's enable-default-label candidate; 'both' (cleared and set,
// e.g. a disable-then-rearm restart in the same function) shows both
// forms, clear before set, instead of collapsing to one plain name.
const flags = r.wFlags;
if (!flags || !flags.length) return r.reg;
const sortedFlags = [...flags].sort((a, b) => a[0].localeCompare(b[0]));
if (!hasEnable) {
const enableBit = sortedFlags.find(([fl, pol]) => isEnableFlagName(fl) && pol !== 'clear');
if (enableBit) { hasEnable = true; enableLabel = shortFlagName(enableBit[0]); }
}
return sortedFlags.map(([fl, pol]) => {
const short = shortFlagName(fl);
return pol === 'both' ? `~${short}, ${short}` : (pol === 'clear' ? '~' : '') + short;
}).join(', ');
}
const flags = r.rFlags;
if (flags && flags.length) {
const sortedFlags = [...flags].sort();
return sortedFlags.map(shortFlagName).join(', ');
}
return r.reg;
});
const shown = names.slice(0, cap);
if (names.length > cap) shown.push(`+${names.length - cap}`);
return { detail: shown.join('\\n'), hasEnable, enableLabel };
}
// Two edges between the exact same pair of nodes but opposite direction (a
// write-direction access edge and its read-direction sibling) otherwise
// draw as one perfectly straight, perfectly coincident line — client-side
// DOM equivalent of index.mjs's bendAntiParallelEdges (see there for the
// full rationale, incl. why tailport/headport was tried and reverted).
// Bends each half of a real anti-parallel pair into a gentle bow after
// layout, endpoints unchanged, and rotates the arrowhead to match.
//
// No explicit left/right sign needed: the reverse edge of a pair runs the
// *same* physical line from the opposite end, so its own (dx,dy) — and
// therefore its own perpendicular — already points the other way on its
// own.
function bendOneEdge(el, path, polygon) {
const d = path.getAttribute('d');
const nums = d && d.match(/-?\d+(?:\.\d+)?/g);
if (!nums || nums.length < 4) return;
const x1 = parseFloat(nums[0]), y1 = parseFloat(nums[1]);
const x2 = parseFloat(nums[nums.length - 2]), y2 = parseFloat(nums[nums.length - 1]);
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy);
if (len < 1) return; // coincident nodes — nothing sane to bend
const midX = (x1 + x2) / 2, midY = (y1 + y2) / 2;
const bend = Math.min(18, len * 0.16);
const cx = midX + (-dy / len) * bend, cy = midY + (dx / len) * bend;
path.setAttribute('d', `M${x1.toFixed(2)},${y1.toFixed(2)} Q${cx.toFixed(2)},${cy.toFixed(2)} ${x2.toFixed(2)},${y2.toFixed(2)}`);
if (polygon) {
// a quadratic bezier's tangent at the endpoint points from the control
// point straight to the endpoint — rotate the arrowhead to match it.
const rot = Math.atan2(y2 - cy, x2 - cx) - Math.atan2(dy, dx);
const cos = Math.cos(rot), sin = Math.sin(rot);
const pts = polygon.getAttribute('points').trim().split(/\s+/).map(p => {
const [px, py] = p.split(',').map(Number);
const rx = px - x2, ry = py - y2;
return `${(x2 + rx * cos - ry * sin).toFixed(2)},${(y2 + rx * sin + ry * cos).toFixed(2)}`;
}).join(' ');
polygon.setAttribute('points', pts);
}
// the label (if any) was placed by graphviz for the *straight* line —
// shift it by the same offset the control point moved off the
// straight-line midpoint, or it's left stranded where the line used to
// run instead of following the curve it's actually sitting on now.
const offX = cx - midX, offY = cy - midY;
el.querySelectorAll('text').forEach(t => {
t.setAttribute('x', (parseFloat(t.getAttribute('x')) + offX).toFixed(2));
t.setAttribute('y', (parseFloat(t.getAttribute('y')) + offY).toFixed(2));
});
}
function bendAntiParallelEdges(svgRoot) {
const edges = [];
svgRoot.querySelectorAll('g.edge').forEach(el => {
const title = el.querySelector('title');
const parts = title ? title.textContent.split('->') : null;
const path = parts && parts.length === 2 && el.querySelector('path');
if (path) edges.push({ el, from: parts[0], to: parts[1], path });
});
const pairKeys = new Set(edges.map(e => `${e.from}>${e.to}`));
for (const e of edges) {
if (pairKeys.has(`${e.to}>${e.from}`)) bendOneEdge(e.el, e.path, e.el.querySelector('polygon'));
}
}
// client-side DOM equivalent of index.mjs's injectPeriphDetailLabels
// (string/regex there, real DOM here since this runs in the browser).
// `details` is the {from, to, detail} list addPeriphOf collected while
// building the edges. Revealed by the same .periph-detail/.periph-default
// CSS pair keyed off .hl that the existing hover system already toggles —
// no extra listeners needed for the swap itself.
// perpendicular push, off graphviz's own on-the-line anchor point —
// mirrors index.mjs's pushLabelPerp/pathPoints exactly (see there for the
// full rationale): two tiers, not one flat distance — the always-visible
// default label sits close (PUSH_NEAR), the hover-revealed detail stack
// sits further out, growing with how many lines it has to fit (PUSH_NEAR +
// PUSH_PER_LINE * lines). Direction comes from the path's local segment
// nearest the label's own point, not the overall start-to-end chord — a
// longer edge routed around other nodes can have local direction that
// differs noticeably from its overall chord.
const PERIPH_LABEL_PUSH_NEAR = 8;
const PERIPH_LABEL_PUSH_PER_LINE = 6;
function pathPoints(pathEl) {
const d = pathEl && pathEl.getAttribute('d');
const nums = d && d.match(/-?\d+(?:\.\d+)?/g);
if (!nums || nums.length < 4) return null;
const pts = [];
for (let i = 0; i + 1 < nums.length; i += 2) pts.push([parseFloat(nums[i]), parseFloat(nums[i + 1])]);
return pts;
}
function pushLabelPerp(pathEl, x0, y0, dist) {
const pts = pathPoints(pathEl);
if (!pts || pts.length < 2) return { x: x0, y: y0 };
let best = null, bestDist = Infinity;
for (let i = 0; i + 1 < pts.length; i++) {
const [x1, y1] = pts[i], [x2, y2] = pts[i + 1];
const d = Math.hypot((x1 + x2) / 2 - x0, (y1 + y2) / 2 - y0);
if (d < bestDist) { bestDist = d; best = [x1, y1, x2, y2]; }
}
const [x1, y1, x2, y2] = best;
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy);
if (len < 1) return { x: x0, y: y0 };
return { x: x0 + (-dy / len) * dist, y: y0 + (dx / len) * dist };
}
function injectPeriphDetailLabels(svgRoot, details) {
if (!details.length) return;
const byPair = new Map();
svgRoot.querySelectorAll('g.edge').forEach(el => {
const title = el.querySelector('title');
const parts = title ? title.textContent.split('->') : null;
if (parts && parts.length === 2) byPair.set(parts[0] + '>' + parts[1], el);
});
for (const { from, to, detail } of details) {
const el = byPair.get(from + '>' + to);
const text = el && el.querySelector('text');
if (!text) continue;
text.classList.add('periph-default');
const path = el.querySelector('path');
const origX = parseFloat(text.getAttribute('x')), origY = parseFloat(text.getAttribute('y'));
const lines = detail.split('\\n');
const near = pushLabelPerp(path, origX, origY, PERIPH_LABEL_PUSH_NEAR);
const far = pushLabelPerp(path, origX, origY, PERIPH_LABEL_PUSH_NEAR + PERIPH_LABEL_PUSH_PER_LINE * lines.length);
text.setAttribute('x', near.x.toFixed(2));
text.setAttribute('y', near.y.toFixed(2));
const dy = 12;
const baseY = far.y - dy * (lines.length - 1) / 2;
const clones = lines.map((line, i) => {
const clone = text.cloneNode(false);
clone.classList.remove('periph-default');
clone.classList.add('periph-detail');
clone.setAttribute('x', far.x.toFixed(2));
clone.setAttribute('y', (baseY + dy * i).toFixed(2));
clone.textContent = line;
return clone;
});
text.after(...clones);
}
}
const relCallEdge = (callerId, calleeId) => ` ${callerId} -> ${calleeId} [dir=forward, style=dashed];`;
// always 0-2 separate directed lines (never dir=both) — see
// dotPeriphAccessEdges/dotAccessEdges in index.mjs for why: a register or
// var that's both read and written gets its own write edge and read edge,
// each with its own label, instead of one double-headed arrow. Endpoints
// stay at the plain node centers — see bendAntiParallelEdges for how the
// resulting coincident write/read lines get visually told apart instead.
// dashedRead: periph edges are solid=write/dashed=read (see
// dotPeriphAccessEdges in index.mjs) — plain variable access edges never
// used dashed for direction, only for call edges (relCallEdge above), so
// this only applies when addPeriphOf asks for it.
function relAccessEdge(fnIdStr, otherIdStr, mode, wLabel = '', rLabel = '', dashedRead = false) {
// len: see dotEdge's comment in index.mjs — a labeled edge otherwise gets
// no extra room reserved for the text, and a short one can spill it onto
// a node. Only engages neato/fdp; dot ignores it.
const lines = [];
if (mode.includes('w')) {
const attrs = wLabel ? ` label="${escHtml(wLabel)}", len=3.5` : '';
lines.push(` ${fnIdStr} -> ${otherIdStr} [dir=forward${attrs}];`);
}
if (mode.includes('r')) {
const attrs = (rLabel ? ` label="${escHtml(rLabel)}", len=3.5` : '') + (dashedRead ? ', style=dashed' : '');
lines.push(` ${otherIdStr} -> ${fnIdStr} [dir=forward${attrs}];`);
}
return lines;
}
function setupRelationsDiagram(diagram) {
const focusId = diagram.dataset.focus;
const G = nodes();
if (!focusId || !G[focusId]) return null; // no adjacency data — leave the static SVG alone
let curEngine = diagram.dataset.curEngine;
let showVars = true;
let upPath = []; // chain of caller ids, focus outward
let downPath = []; // chain of callee ids, focus outward
let rendering = false, pending = false;
// Positions (inches, graphviz's own space) from the previous pinned
// render, keyed by node id, plus that layout's total graph height (inches)
// — both needed to hold already-visible nodes still on the next click
// instead of letting the whole diagram reshuffle. Every id present here is
// re-emitted next render with pos="x,y!", which nails it in place; only the
// freshly-added frontier nodes are free to move. graphviz still shifts the
// whole pinned cluster as one rigid block to make room for the newcomers
// (relative positions are preserved exactly), so render() pans .inner by
// the focus node's before/after delta to cancel that block shift out.
// Only meaningful for neato/fdp: dot is rank-based with no notion of
// "start from this position", so lastPos is left null (and pinning skipped)
// there.
let lastPos = null;
let lastHeight = 0;
// dot has no notion of a pinned x,y, but reordering *within* a rank
// (dot's crossing-minimization re-deciding top-to-bottom order every
// render) turned out to be the actual source of "everything jumps" for
// dot — not the rank/column assignment itself, which is already stable
// since it's ours (see rankKeyOf: derived from depthOf, not from dot's own
// longest-path computation). lastOrder remembers, per rank, the sequence
// nodes appeared in last render; buildDot() re-asserts that sequence via
// an explicit `{rank=same; ...}` group plus a chain of invisible
// (style=invis) ordering edges — the standard graphviz technique for
// controlling in-rank order. New nodes are appended after the kept ones.
// This is a bias, not a hard guarantee: a real call edge between two
// rank-mates (a sibling calling a sibling) imposes its own precedence
// that can override an arbitrary requested order — topoOrder() below
// resolves that by never requesting an order that contradicts a real
// edge in the first place. Measured across 15 real functions / 26 clicks:
// 0 unresolved reorders; one deep (29-node) synthetic case needed one
// settle-in swap that then held stable, never a repeat reshuffle.
let lastOrder = null; // rankKey -> [ids in previous top-to-bottom order]
// Stable order for one rank's nodes: respects every real edge between two
// rank-mates (tail must precede head — otherwise the invisible ordering
// edge we're about to add would directly contradict a real one, which is
// what let dot override our request in the first place), and among nodes
// free of such constraints, prefers preferredOrder (kept-from-last-render
// nodes first, in their old sequence, then newly-added ones). Plain Kahn's
// algorithm; a cycle (two rank-mates calling each other both ways) is
// vanishingly rare for real call graphs, so on one, whatever's left just
// gets appended rather than looping forever.
function topoOrder(ids, precedenceEdges, preferredOrder) {
const prefIndex = new Map(preferredOrder.map((id, i) => [id, i]));
const idsSet = new Set(ids);
const adj = new Map(ids.map(id => [id, []]));
const indeg = new Map(ids.map(id => [id, 0]));
for (const [f, t] of precedenceEdges) {
if (!idsSet.has(f) || !idsSet.has(t) || f === t) continue;
adj.get(f).push(t);
indeg.set(t, indeg.get(t) + 1);
}
const cmp = (a, b) => {
const pa = prefIndex.has(a) ? prefIndex.get(a) : Infinity;
const pb = prefIndex.has(b) ? prefIndex.get(b) : Infinity;
return pa !== pb ? pa - pb : ids.indexOf(a) - ids.indexOf(b);
};
const ready = ids.filter(id => indeg.get(id) === 0).sort(cmp);
const result = [], done = new Set();
while (ready.length) {
ready.sort(cmp);
const id = ready.shift();
result.push(id);
done.add(id);
for (const nb of adj.get(id)) {
indeg.set(nb, indeg.get(nb) - 1);
if (indeg.get(nb) === 0 && !done.has(nb)) ready.push(nb);
}
}
for (const id of ids) if (!done.has(id)) result.push(id); // cycle leftovers
return result;
}
// plain-format layout: node lines are "node <id> <x> <y> <w> <h> ...",
// coords in inches, origin bottom-left; the leading "graph 1 <w> <h>" line
// carries the whole graph's size. That's exactly the space pos="x,y!" reads
// back, so a plain layout is the round-trip source for pinning (the SVG's
// point coordinates would need un-flipping and margin bookkeeping first).
function parsePlain(txt) {
const pos = new Map();
let height = 0;
for (const line of txt.split('\n')) {
const t = line.trim().split(/\s+/);
if (t[0] === 'graph') height = parseFloat(t[3]);
else if (t[0] === 'node') pos.set(t[1], { x: parseFloat(t[2]), y: parseFloat(t[3]) });
}
return { pos, height };
}
// Builds the dot text for "everything that should currently be visible":
// focus, its base callers/callees, every already-chosen link in
// upPath/downPath, and — for each of those two chains — one more level
// (the "frontier": candidates the user hasn't picked between yet, shown
// at full color since nothing there has been narrowed down). Variables
// are only ever shown for focus itself — this diagram is about the call
// *chain*, and a chain node's own reads/writes turned out to be mostly
// noise once a couple of hops deep (they visually compete with the call
// edges for the same "upstream/downstream" direction).
function buildDot() {
const nodeLines = [];
const edgeLines = [];
const edgeKeys = new Set(); // "from>to", so a pair already drawn (by the
// walk below or by the cross-link pass at the end) is never duplicated
// into a second overlapping spline
const seenNodes = new Set();
const fullColor = new Set([focusId]);
const depthOf = new Map(); // id -> { side: 'up'|'down', depth }
const focusFile = G[focusId].file;
const edgeDetails = []; // {from, to, detail} — see injectPeriphDetailLabels
// Re-emit a previously-laid-out node at its old spot so this render holds
// it still; new nodes (and everything under dot, which can't be pinned)
// get no pos and are placed freely. See lastPos above.
const canPin = curEngine !== 'dot' && lastPos;
function pinOf(id) {
if (!canPin) return '';
const p = lastPos.get(id);
return p ? ` pos="${p.x},${p.y}!"` : '';
}
// dot-only bookkeeping for in-rank ordering (see lastOrder above): every
// real call edge (candidate for a same-rank precedence constraint), and
// — for the var/periph nodes, which have no depthOf entry since they
// only ever attach to focus — the access mode that decides which side
// of focus they land on (mirrors the direction relAccessEdge draws).
const edgePairs = [];
const varPeriphMode = new Map(); // id -> 'r' | 'w' | 'rw'
function ensureFn(id) {
if (seenNodes.has(id) || !G[id]) return;
seenNodes.add(id);
nodeLines.push(relFnNodeLine(id, G[id], G[id].file === focusFile, pinOf(id)));
}
function addCallEdge(from, to) {
const ek = from + '>' + to;
if (edgeKeys.has(ek)) return;
edgeKeys.add(ek);
edgeLines.push(relCallEdge(from, to));
edgePairs.push([from, to]);
}
function addVarsOf(fid) {
if (!showVars) return;
for (const a of (G[fid].access || [])) {
if (!seenNodes.has(a.v) && G[a.v]) {
seenNodes.add(a.v);
nodeLines.push(relVarNodeLine(a.v, G[a.v], G[a.v].file === focusFile, pinOf(a.v)));
}
edgeLines.push(...relAccessEdge(fid, a.v, a.mode));
fullColor.add(a.v);
varPeriphMode.set(a.v, a.mode);
}
}
// peripherals are shown, like variables, only for the focus itself and
// only while the "переменные" toggle is on — they're the same data-access
// concern, just against a hardware register block instead of a global.
// No longer folds in a separate "armed" fact (arming has its own honest
// home now — a synthetic NVIC node, same as index.mjs's
// dotPeriphAccessEdges — see there for the full rationale): this edge
// is purely real register access, read or write.
function addPeriphOf(fid) {
if (!showVars) return;
for (const pa of (G[fid].periph || [])) {
if (!seenNodes.has(pa.id)) {
seenNodes.add(pa.id);
nodeLines.push(relPeriphNodeLine(pa.id, pa.name, pinOf(pa.id)));
}
const w = relPeriphDirDetail(pa.regs, 'w');
const r = relPeriphDirDetail(pa.regs, 'r');
const mode = (r.detail ? 'r' : '') + (w.detail ? 'w' : '');
// RCC excluded from the callout (its enable bits are clock gates
// for *other* peripherals, not a fact about RCC itself — see
// index.mjs's dotPeriphAccessEdges for the full rationale).
const wDefault = pa.name === 'RCC' ? '' : (w.hasEnable ? w.enableLabel : '');
// a blank default still needs *some* label so graphviz reserves a
// real text anchor for injectPeriphDetailLabels to clone from.
const wLabel = w.detail ? (wDefault || ' ') : wDefault;
const rLabel = r.detail ? ' ' : '';
edgeLines.push(...relAccessEdge(fid, pa.id, mode, wLabel, rLabel, true));
if (w.detail) edgeDetails.push({ from: fid, to: pa.id, detail: w.detail });
if (r.detail) edgeDetails.push({ from: pa.id, to: fid, detail: r.detail });
fullColor.add(pa.id);
varPeriphMode.set(pa.id, mode);
}
}
ensureFn(focusId);
addVarsOf(focusId);
addPeriphOf(focusId);
function walk(side, path, adjKey, dirOf) {
let prev = focusId;
for (let i = 0; i < path.length; i++) {
for (const s of (G[prev][adjKey] || [])) {
ensureFn(s);
const [from, to] = dirOf(s, prev);
addCallEdge(from, to);
if (!depthOf.has(s)) depthOf.set(s, { side, depth: i });
}
prev = path[i];
}
for (const s of (G[prev][adjKey] || [])) {
ensureFn(s);
const [from, to] = dirOf(s, prev);
addCallEdge(from, to);
fullColor.add(s);
if (!depthOf.has(s)) depthOf.set(s, { side, depth: path.length });
}
for (const p of path) fullColor.add(p);
}
walk('up', upPath, 'callers', (s, prev) => [s, prev]);
walk('down', downPath, 'calls', (s, prev) => [prev, s]);
// Connect any two nodes that are *both* already on screen, even when
// neither is on the currently-drilled path — e.g. two sibling callees
// that happen to call each other. Without this, that relationship only
// showed up once you separately drilled into one of them, even though
// both ends were already visible.
for (const id of seenNodes) {
const info = G[id];
if (!info || !Array.isArray(info.calls)) continue; // skip var nodes
for (const c of info.calls) {
if (seenNodes.has(c)) addCallEdge(id, c);
}
}
// dot only: force each node's rank explicitly from depthOf/varPeriphMode
// (never from dot's own longest-path computation, which a fresh cross-
// link could otherwise shift a node's rank on any click) and re-assert
// the previous in-rank order via invisible edges — see lastOrder above.
const orderLines = [];
if (curEngine === 'dot') {
const rankOf = id => {
if (id === focusId) return 0;
const d = depthOf.get(id);
if (d) return d.side === 'up' ? -(d.depth + 1) : (d.depth + 1);
const m = varPeriphMode.get(id);
return m ? (m.includes('w') ? 1 : -1) : null;
};
const groups = new Map(); // rank -> [ids]
for (const id of seenNodes) {
const rk = rankOf(id);
if (rk === null || rk === 0) continue;
if (!groups.has(rk)) groups.set(rk, []);
groups.get(rk).push(id);
}
const newOrder = new Map();
for (const [rk, ids] of groups) {
const idsSet = new Set(ids);
const precedence = edgePairs.filter(([f, t]) => idsSet.has(f) && idsSet.has(t));
const prev = (lastOrder && lastOrder.get(rk)) || [];
const kept = prev.filter(id => idsSet.has(id));
const fresh = ids.filter(id => !kept.includes(id));
const order = topoOrder(ids, precedence, [...kept, ...fresh]);
newOrder.set(rk, order);
orderLines.push(` { rank=same; ${order.join('; ')}; }`);
for (let i = 0; i < order.length - 1; i++) {
orderLines.push(` ${order[i]} -> ${order[i + 1]} [style=invis, weight=100];`);
}
}
lastOrder = newOrder;
}
return { dot: [...nodeLines, ...edgeLines, ...orderLines].join('\n'), fullColor, depthOf, edgeDetails };
}
function wireRelationsNodes(svg, depthOf) {
const nodeEls = new Map(); // id -> [el] (single-element, but same shape as setupGraphvizSvg)
svg.querySelectorAll('g.node[id]').forEach(el => {
if (G[el.id]) nodeEls.set(el.id, [el]);
});
const edges = [];
svg.querySelectorAll('g.edge').forEach(el => {
const title = el.querySelector('title');
const parts = title ? title.textContent.split('->') : null;
if (parts && parts.length === 2 && G[parts[0]] && G[parts[1]]) edges.push({ el, from: parts[0], to: parts[1] });
});
// Hover-highlight a node/edge together with everything connected to it,
// same idea as setupGraphvizSvg elsewhere on the site — but this
// diagram's svg is *permanently* faded (render() always adds 'fade';
// 'hl' marks the confirmed chain, not a transient hover state), so
// clearing by wiping every '.hl' on mouseleave would erase that
// permanent marking too. An element that's already full-color (already
// 'hl' from the confirmed-chain marking) has no opacity left to gain