Skip to content

Commit 22d368a

Browse files
feat(network): add ctrl+click drill-down with breadcrumb (#702)
1 parent 5f5bb28 commit 22d368a

6 files changed

Lines changed: 368 additions & 3 deletions

File tree

.changeset/slick-beds-share.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@nodesecure/vis-network": patch
3+
---
4+
5+
Skip `neighbourHighlight` on Ctrl+Click and Cmd+Click to allow drill-down interactions without conflicting focus animations
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Import Third-party Dependencies
2+
import { LitElement, html, css, nothing } from "lit";
3+
4+
// Import Internal Dependencies
5+
import { EVENTS } from "../../core/events.js";
6+
7+
class DrillBreadcrumb extends LitElement {
8+
static styles = css`
9+
:host {
10+
position: absolute;
11+
top: 38px;
12+
left: 10px;
13+
z-index: 20;
14+
display: flex;
15+
align-items: center;
16+
gap: 4px;
17+
background: rgb(10 10 20 / 72%);
18+
border-radius: 6px;
19+
padding: 4px 10px;
20+
font-family: mononoki, monospace;
21+
font-size: 12px;
22+
color: #fff;
23+
}
24+
25+
:host([hidden]) {
26+
display: none !important;
27+
}
28+
29+
button {
30+
background: transparent;
31+
border: none;
32+
color: rgb(255 255 255 / 85%);
33+
cursor: pointer;
34+
font-family: mononoki, monospace;
35+
font-size: 12px;
36+
padding: 0;
37+
}
38+
39+
button:hover {
40+
color: #fff;
41+
text-decoration: underline;
42+
}
43+
44+
.separator-wrapper {
45+
position: relative;
46+
display: inline-flex;
47+
align-items: center;
48+
}
49+
50+
.separator {
51+
opacity: 0.5;
52+
cursor: default;
53+
text-decoration: none !important;
54+
}
55+
56+
.separator.has-siblings {
57+
cursor: pointer;
58+
opacity: 0.8;
59+
}
60+
61+
.separator.has-siblings:hover {
62+
opacity: 1;
63+
color: var(--secondary);
64+
text-decoration: none;
65+
}
66+
67+
.active {
68+
color: var(--secondary);
69+
font-weight: bold;
70+
}
71+
72+
.dropdown {
73+
position: absolute;
74+
top: calc(100% + 6px);
75+
left: 50%;
76+
transform: translateX(-50%);
77+
background: rgb(10 10 20 / 95%);
78+
border: 1px solid rgb(255 255 255 / 15%);
79+
border-radius: 6px;
80+
padding: 4px 0;
81+
min-width: 180px;
82+
max-height: 260px;
83+
overflow-y: auto;
84+
z-index: 30;
85+
box-shadow: 0 4px 16px rgb(0 0 0 / 50%);
86+
}
87+
88+
.dropdown button {
89+
display: block;
90+
width: 100%;
91+
text-align: left;
92+
padding: 5px 12px;
93+
white-space: nowrap;
94+
color: rgb(255 255 255 / 80%);
95+
font-size: 11px;
96+
border-radius: 0;
97+
}
98+
99+
.dropdown button:hover {
100+
background: rgb(255 255 255 / 10%);
101+
color: #fff;
102+
text-decoration: none;
103+
}
104+
`;
105+
106+
static properties = {
107+
root: { type: Object },
108+
stack: { type: Array },
109+
siblings: { type: Array },
110+
_openDropdown: { state: true }
111+
};
112+
113+
constructor() {
114+
super();
115+
this.root = null;
116+
this.stack = [];
117+
this.siblings = [];
118+
this._openDropdown = null;
119+
}
120+
121+
connectedCallback() {
122+
super.connectedCallback();
123+
document.addEventListener("click", this.#handleDocumentClick);
124+
}
125+
126+
disconnectedCallback() {
127+
super.disconnectedCallback();
128+
document.removeEventListener("click", this.#handleDocumentClick);
129+
}
130+
131+
updated() {
132+
this.hidden = this.stack.length === 0 || this.root === null;
133+
}
134+
135+
#handleDocumentClick = () => {
136+
if (this._openDropdown !== null) {
137+
this._openDropdown = null;
138+
}
139+
};
140+
141+
#handleReset() {
142+
this.dispatchEvent(new CustomEvent(EVENTS.DRILL_RESET, {
143+
bubbles: true,
144+
composed: true
145+
}));
146+
}
147+
148+
#handleBack(index) {
149+
this.dispatchEvent(new CustomEvent(EVENTS.DRILL_BACK, {
150+
detail: { index },
151+
bubbles: true,
152+
composed: true
153+
}));
154+
}
155+
156+
#toggleDropdown(index, event) {
157+
event.stopPropagation();
158+
159+
this._openDropdown = this._openDropdown === index ? null : index;
160+
}
161+
162+
#handleSiblingClick(stackIndex, nodeId, event) {
163+
event.stopPropagation();
164+
165+
this._openDropdown = null;
166+
this.dispatchEvent(new CustomEvent(EVENTS.DRILL_SWITCH, {
167+
detail: { stackIndex, nodeId },
168+
bubbles: true,
169+
composed: true
170+
}));
171+
}
172+
173+
render() {
174+
if (this.stack.length === 0 || this.root === null) {
175+
return nothing;
176+
}
177+
178+
return html`
179+
<button @click="${this.#handleReset}">${this.root.name}@${this.root.version}</button>
180+
${this.stack.map((entry, stackIndex) => {
181+
const siblingList = this.siblings?.[stackIndex] ?? [];
182+
const hasSiblings = siblingList.length > 0;
183+
184+
return html`
185+
<span class="separator-wrapper">
186+
${hasSiblings
187+
? html`
188+
<button
189+
class="separator has-siblings"
190+
@click="${(event) => this.#toggleDropdown(stackIndex, event)}"
191+
></button>
192+
${this._openDropdown === stackIndex ? html`
193+
<div class="dropdown">
194+
${siblingList.map((sibling) => html`
195+
<button @click="${(event) => this.#handleSiblingClick(stackIndex, sibling.nodeId, event)}">
196+
${sibling.name}@${sibling.version}
197+
</button>
198+
`)}
199+
</div>
200+
` : nothing}
201+
`
202+
: html`<span class="separator"></span>`
203+
}
204+
</span>
205+
${stackIndex === this.stack.length - 1
206+
? html`<span class="active">${entry.name}@${entry.version}</span>`
207+
: html`<button @click="${() => this.#handleBack(stackIndex)}">${entry.name}@${entry.version}</button>`
208+
}
209+
`;
210+
})}
211+
`;
212+
}
213+
}
214+
215+
customElements.define("drill-breadcrumb", DrillBreadcrumb);

public/core/events.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,8 @@ export const EVENTS = {
1010
MODAL_OPENED: "modal-opened",
1111
NETWORK_VIEW_HID: "network-view-hid",
1212
NETWORK_VIEW_SHOWED: "network-view-showed",
13-
SEARCH_COMMAND_INIT: "search-command-init"
13+
SEARCH_COMMAND_INIT: "search-command-init",
14+
DRILL_RESET: "drill-reset",
15+
DRILL_BACK: "drill-back",
16+
DRILL_SWITCH: "drill-switch"
1417
};

public/main.js

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import "./components/search-command/search-command.js";
1313
import { Settings } from "./components/views/settings/settings.js";
1414
import { HomeView } from "./components/views/home/home.js";
1515
import "./components/views/search/search.js";
16+
import "./components/drill-breadcrumb/drill-breadcrumb.js";
1617
import { NetworkNavigation } from "./core/network-navigation.js";
1718
import { i18n } from "./core/i18n.js";
1819
import { initSearchNav } from "./core/search-nav.js";
@@ -24,7 +25,9 @@ let secureDataSet;
2425
let nsn;
2526
let homeView;
2627
let searchview;
28+
let drillBreadcrumb;
2729
let packageInfoOpened = false;
30+
const drillStack = [];
2831

2932
document.addEventListener("DOMContentLoaded", async() => {
3033
searchview = document.querySelector("search-view");
@@ -39,6 +42,17 @@ document.addEventListener("DOMContentLoaded", async() => {
3942
// update searchview after window.i18n is set
4043
searchview.requestUpdate();
4144

45+
drillBreadcrumb = document.querySelector("drill-breadcrumb");
46+
drillBreadcrumb.addEventListener(EVENTS.DRILL_RESET, resetDrill);
47+
drillBreadcrumb.addEventListener(EVENTS.DRILL_BACK, function handleDrillBack(event) {
48+
drillBackTo(event.detail.index);
49+
});
50+
drillBreadcrumb.addEventListener(EVENTS.DRILL_SWITCH, function handleDrillSwitch(event) {
51+
const { stackIndex, nodeId } = event.detail;
52+
drillStack.length = stackIndex;
53+
drillInto(nodeId);
54+
});
55+
4256
await init();
4357
window.dispatchEvent(
4458
new CustomEvent(EVENTS.SETTINGS_SAVED, {
@@ -122,6 +136,107 @@ function dispatchSearchCommandInit() {
122136
window.dispatchEvent(event);
123137
}
124138

139+
function computeSiblings(parentId, excludeId) {
140+
const seen = new Set();
141+
const result = [];
142+
143+
for (const edge of secureDataSet.rawEdgesData) {
144+
if (edge.to === parentId && edge.from !== excludeId && !seen.has(edge.from)) {
145+
seen.add(edge.from);
146+
147+
const entry = secureDataSet.linker.get(edge.from);
148+
result.push({
149+
nodeId: edge.from,
150+
name: entry.name,
151+
version: entry.version
152+
});
153+
}
154+
}
155+
156+
return result.sort((nodeA, nodeB) => nodeA.name.localeCompare(nodeB.name));
157+
}
158+
159+
function computeDrillSubtree(rootNodeId) {
160+
const subtreeIds = new Set([rootNodeId]);
161+
const queue = [rootNodeId];
162+
163+
while (queue.length > 0) {
164+
const current = queue.shift();
165+
for (const edge of secureDataSet.rawEdgesData) {
166+
if (edge.to === current && !subtreeIds.has(edge.from)) {
167+
subtreeIds.add(edge.from);
168+
queue.push(edge.from);
169+
}
170+
}
171+
}
172+
173+
return subtreeIds;
174+
}
175+
176+
function applyDrill(nodeId) {
177+
const subtreeIds = computeDrillSubtree(nodeId);
178+
const updates = [...secureDataSet.linker.keys()].map((id) => {
179+
return {
180+
id,
181+
hidden: !subtreeIds.has(id)
182+
};
183+
});
184+
nsn.nodes.update(updates);
185+
nsn.network.unselectAll();
186+
updateDrillBreadcrumb();
187+
PackageInfo.close();
188+
nsn.neighbourHighlight({ nodes: [nodeId], edges: [] });
189+
}
190+
191+
function drillInto(nodeId) {
192+
const currentRoot = drillStack.length === 0 ? 0 : drillStack.at(-1);
193+
if (nodeId === currentRoot) {
194+
return;
195+
}
196+
197+
drillStack.push(nodeId);
198+
applyDrill(nodeId);
199+
}
200+
201+
function drillBackTo(stackIndex) {
202+
drillStack.length = stackIndex + 1;
203+
applyDrill(drillStack[stackIndex]);
204+
}
205+
206+
function resetDrill() {
207+
drillStack.length = 0;
208+
const updates = [...secureDataSet.linker.keys()].map((id) => {
209+
return {
210+
id,
211+
hidden: false
212+
};
213+
});
214+
nsn.nodes.update(updates);
215+
updateDrillBreadcrumb();
216+
PackageInfo.close();
217+
}
218+
219+
function updateDrillBreadcrumb() {
220+
const rootEntry = secureDataSet.linker.get(0);
221+
drillBreadcrumb.root = {
222+
name: rootEntry.name,
223+
version: rootEntry.version
224+
};
225+
drillBreadcrumb.stack = drillStack.map((nodeId) => {
226+
const entry = secureDataSet.linker.get(nodeId);
227+
228+
return {
229+
name: entry.name,
230+
version: entry.version
231+
};
232+
});
233+
drillBreadcrumb.siblings = drillStack.map((nodeId, index) => {
234+
const parentId = index === 0 ? 0 : drillStack[index - 1];
235+
236+
return computeSiblings(parentId, nodeId);
237+
});
238+
}
239+
125240
async function init(options = {}) {
126241
const { navigateToNetworkView = false } = options;
127242

@@ -158,7 +273,22 @@ async function init(options = {}) {
158273
packageInfoOpened = false;
159274
});
160275

161-
nsn.network.on("click", updateShowInfoMenu);
276+
nsn.network.on("click", (params) => {
277+
const srcEvent = params.event?.srcEvent;
278+
const isDrillClick = srcEvent?.ctrlKey || srcEvent?.metaKey;
279+
280+
if (isDrillClick && params.nodes.length > 0) {
281+
const nodeId = Number(params.nodes[0]);
282+
drillInto(nodeId);
283+
284+
return;
285+
}
286+
287+
updateShowInfoMenu(params);
288+
});
289+
290+
drillStack.length = 0;
291+
updateDrillBreadcrumb();
162292

163293
const networkNavigation = new NetworkNavigation(secureDataSet, nsn);
164294
window.networkNav = networkNavigation;

0 commit comments

Comments
 (0)