| … | … | @@ -51,6 +51,11 @@ export const NODE_VIEW_CSS = ` |
| 51 | 51 | .nv-frame { fill-opacity: 0.10; stroke-opacity: 0.55; stroke-width: 1.5; rx: 8; } |
| 52 | 52 | .nv-frame-label { font-size: 12px; font-weight: bold; fill-opacity: 0.95; pointer-events: none; } |
| 53 | 53 | .nv-junction { fill: #5a6570; stroke: none; } |
| 54 | .nv-noodle.hot { stroke: #ffa657 !important; stroke-width: 2.2; } |
| 55 | .nv-noodle.cold { opacity: 0.06; } |
| 56 | .nv-noodle.dep { stroke-width: 2; } |
| 57 | .nv-node.dim { opacity: 0.25; } |
| 58 | .nv-hops { font-size: 8px; font-family: ui-monospace, monospace; } |
| 54 | 59 | .nv-noodle { stroke-linejoin: round; stroke-linecap: round; } |
| 55 | 60 | |
| 56 | 61 | #node-breadcrumb { |
| … | … | @@ -103,6 +108,9 @@ var NV = { |
| 103 | 108 | sourceCache: {}, |
| 104 | 109 | frames: [], |
| 105 | 110 | circuit: true, |
| 111 | selected: null, |
| 112 | linkEls: [], |
| 113 | nodeEls: null, |
| 106 | 114 | layoutPending: false, |
| 107 | 115 | selected: null |
| 108 | 116 | } |
| … | … | @@ -609,6 +617,8 @@ function nvRender () { |
| 609 | 617 | // way, rather than a bezier drawn straight through everything between. |
| 610 | 618 | var routed = {} |
| 611 | 619 | var junctions = [] |
| 620 | NV.linkEls = [] |
| 621 | NV.nodeEls = {} |
| 612 | 622 | if (NV.circuit && window.__ctRoute) { |
| 613 | 623 | var boxes = [] |
| 614 | 624 | for (var bi = 0; bi < NV.nodes.length; bi++) { |
| … | … | @@ -654,6 +664,7 @@ function nvRender () { |
| 654 | 664 | ', ' + p2.x + ' ' + p2.y)) |
| 655 | 665 | path.setAttribute('class', 'nv-noodle') |
| 656 | 666 | path.setAttribute('stroke', '#5a6570') |
| 667 | NV.linkEls.push({ el: path, from: lk.from, to: lk.to }) |
| 657 | 668 | noodleG.appendChild(path) |
| 658 | 669 | } |
| 659 | 670 | |
| … | … | @@ -669,6 +680,39 @@ function nvRender () { |
| 669 | 680 | nvDrawFrames(frameG) |
| 670 | 681 | for (var i = 0; i < NV.nodes.length; i++) nvDrawNode(nodeG, NV.nodes[i]) |
| 671 | 682 | |
| 683 | // Dependencies of dependencies: clicking a node traces its WHOLE closure, not |
| 684 | // one hop, with distance carried by colour. Same shared implementation the |
| 685 | // floor uses, so both views agree about what "depends on" means. |
| 686 | if (NV.selected && window.__ct) { |
| 687 | var closure = window.__ct.dependencyClosure(NV.links, NV.selected) |
| 688 | for (var li = 0; li < NV.linkEls.length; li++) { |
| 689 | var le = NV.linkEls[li] |
| 690 | var d = closure.edges.get(le.from + ' -> ' + le.to) |
| 691 | if (d === undefined) { |
| 692 | le.el.classList.add('cold') |
| 693 | } else { |
| 694 | le.el.classList.add('dep') |
| 695 | le.el.setAttribute('stroke', window.__ct.depthColor(d, closure.maxDepth)) |
| 696 | } |
| 697 | } |
| 698 | for (var ni = 0; ni < NV.nodes.length; ni++) { |
| 699 | var nn = NV.nodes[ni] |
| 700 | var nd = closure.nodes.get(nn.id) |
| 701 | if (nd === undefined) { if (nn.el) nn.el.classList.add('dim'); continue } |
| 702 | if (nd === 0 && nn.el) nn.el.classList.add('selected') |
| 703 | if (nd > 0 && nn.el) { |
| 704 | var hops = document.createElementNS('http://www.w3.org/2000/svg', 'text') |
| 705 | hops.setAttribute('class', 'nv-hops') |
| 706 | hops.setAttribute('x', NV_W - 7) |
| 707 | hops.setAttribute('y', nvNodeHeight(nn) - 5) |
| 708 | hops.setAttribute('text-anchor', 'end') |
| 709 | hops.setAttribute('fill', window.__ct.depthColor(nd, closure.maxDepth)) |
| 710 | hops.textContent = nd + (nd === 1 ? ' hop' : ' hops') |
| 711 | nn.el.appendChild(hops) |
| 712 | } |
| 713 | } |
| 714 | } |
| 715 | |
| 672 | 716 | nvApplyTransform() |
| 673 | 717 | nvBreadcrumb() |
| 674 | 718 | nvLegend() |
| … | … | @@ -744,8 +788,41 @@ function nvDrawNode (parent, n) { |
| 744 | 788 | title.addEventListener('click', openIt) |
| 745 | 789 | } |
| 746 | 790 | |
| 791 | // Hover traces this node's own wires; click keeps its whole dependency depth |
| 792 | // on screen until you click it again or hit Escape. |
| 793 | g.addEventListener('mouseenter', function () { if (!NV.selected) nvFocus(n) }) |
| 794 | g.addEventListener('mouseleave', function () { if (!NV.selected) nvUnfocus() }) |
| 795 | g.addEventListener('click', function (e) { |
| 796 | e.stopPropagation() |
| 797 | NV.selected = NV.selected === n.id ? null : n.id |
| 798 | nvRender() |
| 799 | }) |
| 800 | |
| 747 | 801 | parent.appendChild(g) |
| 748 | 802 | n.el = g |
| 803 | NV.nodeEls[n.id] = g |
| 804 | } |
| 805 | |
| 806 | function nvFocus (node) { |
| 807 | var connected = {} |
| 808 | connected[node.id] = true |
| 809 | for (var i = 0; i < NV.linkEls.length; i++) { |
| 810 | var le = NV.linkEls[i] |
| 811 | var hot = le.from === node.id || le.to === node.id |
| 812 | le.el.classList.toggle('hot', hot) |
| 813 | le.el.classList.toggle('cold', !hot) |
| 814 | if (hot) connected[le.from === node.id ? le.to : le.from] = true |
| 815 | } |
| 816 | for (var id in NV.nodeEls) { |
| 817 | NV.nodeEls[id].classList.toggle('dim', !connected[id]) |
| 818 | } |
| 819 | } |
| 820 | |
| 821 | function nvUnfocus () { |
| 822 | for (var i = 0; i < NV.linkEls.length; i++) { |
| 823 | NV.linkEls[i].el.classList.remove('hot', 'cold') |
| 824 | } |
| 825 | for (var id in NV.nodeEls) NV.nodeEls[id].classList.remove('dim') |
| 749 | 826 | } |
| 750 | 827 | |
| 751 | 828 | function nvDrawSocket (g, n, sock, side, row) { |
| … | … | @@ -827,6 +904,7 @@ function nvLegend () { |
| 827 | 904 | '<span style="color:#6e7076">○</span> unmated ' + |
| 828 | 905 | '<span style="color:#f85149">○</span> unresolved' + |
| 829 | 906 | '<br>left sockets demand · right sockets supply' + |
| 907 | '<br>hover traces a node · click for its full dependency depth' + |
| 830 | 908 | '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' + |
| 831 | 909 | '<input type="checkbox" id="nv-only-connected"' + (NV.onlyConnected ? ' checked' : '') + |
| 832 | 910 | ' style="vertical-align:-1px;accent-color:#58a6ff"> collapse to only connected</label>' + |
| … | … | @@ -958,6 +1036,16 @@ function nvInit () { |
| 958 | 1036 | nvApplyTransform() |
| 959 | 1037 | }, { passive: false }) |
| 960 | 1038 | |
| 1039 | svg.addEventListener('click', function (e) { |
| 1040 | if (e.target === svg || e.target.id === 'nv-world') { |
| 1041 | if (NV.selected) { NV.selected = null; nvRender() } |
| 1042 | } |
| 1043 | }) |
| 1044 | |
| 1045 | document.addEventListener('keydown', function (e) { |
| 1046 | if (e.key === 'Escape' && NV.selected) { NV.selected = null; nvRender() } |
| 1047 | }) |
| 1048 | |
| 961 | 1049 | svg.addEventListener('mousedown', function (e) { |
| 962 | 1050 | NV.panning = true |
| 963 | 1051 | NV.panSX = e.clientX - NV.panX |
| … | … | @@ -976,5 +1064,15 @@ function nvInit () { |
| 976 | 1064 | }) |
| 977 | 1065 | |
| 978 | 1066 | nvBuild() |
| 1067 | |
| 1068 | // Deep-linkable, like every other view here, and the only way to review a |
| 1069 | // selection without a mouse: ?select=<node title>. |
| 1070 | var want = new URLSearchParams(location.search).get('select') |
| 1071 | if (want) { |
| 1072 | for (var si = 0; si < NV.nodes.length; si++) { |
| 1073 | if (NV.nodes[si].title === want) { NV.selected = NV.nodes[si].id; break } |
| 1074 | } |
| 1075 | if (NV.selected) nvRender() |
| 1076 | } |
| 979 | 1077 | } |
| 980 | 1078 | ` |