From 95c52965492ce24dc6d007c70c7041513706a96f Mon Sep 17 00:00:00 2001 From: PierreDemailly Date: Thu, 26 Mar 2026 02:00:36 +0100 Subject: [PATCH] feat(network): add ctrl+click drill-down with breadcrumb --- .changeset/slick-beds-share.md | 5 + .../drill-breadcrumb/drill-breadcrumb.js | 215 ++++++++++++++++++ public/core/events.js | 5 +- public/main.js | 132 ++++++++++- views/index.html | 1 + workspaces/vis-network/src/network.ts | 13 +- 6 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 .changeset/slick-beds-share.md create mode 100644 public/components/drill-breadcrumb/drill-breadcrumb.js diff --git a/.changeset/slick-beds-share.md b/.changeset/slick-beds-share.md new file mode 100644 index 00000000..f3aa5e01 --- /dev/null +++ b/.changeset/slick-beds-share.md @@ -0,0 +1,5 @@ +--- +"@nodesecure/vis-network": patch +--- + +Skip `neighbourHighlight` on Ctrl+Click and Cmd+Click to allow drill-down interactions without conflicting focus animations diff --git a/public/components/drill-breadcrumb/drill-breadcrumb.js b/public/components/drill-breadcrumb/drill-breadcrumb.js new file mode 100644 index 00000000..1b538cb7 --- /dev/null +++ b/public/components/drill-breadcrumb/drill-breadcrumb.js @@ -0,0 +1,215 @@ +// Import Third-party Dependencies +import { LitElement, html, css, nothing } from "lit"; + +// Import Internal Dependencies +import { EVENTS } from "../../core/events.js"; + +class DrillBreadcrumb extends LitElement { + static styles = css` + :host { + position: absolute; + top: 38px; + left: 10px; + z-index: 20; + display: flex; + align-items: center; + gap: 4px; + background: rgb(10 10 20 / 72%); + border-radius: 6px; + padding: 4px 10px; + font-family: mononoki, monospace; + font-size: 12px; + color: #fff; + } + + :host([hidden]) { + display: none !important; + } + + button { + background: transparent; + border: none; + color: rgb(255 255 255 / 85%); + cursor: pointer; + font-family: mononoki, monospace; + font-size: 12px; + padding: 0; + } + + button:hover { + color: #fff; + text-decoration: underline; + } + + .separator-wrapper { + position: relative; + display: inline-flex; + align-items: center; + } + + .separator { + opacity: 0.5; + cursor: default; + text-decoration: none !important; + } + + .separator.has-siblings { + cursor: pointer; + opacity: 0.8; + } + + .separator.has-siblings:hover { + opacity: 1; + color: var(--secondary); + text-decoration: none; + } + + .active { + color: var(--secondary); + font-weight: bold; + } + + .dropdown { + position: absolute; + top: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + background: rgb(10 10 20 / 95%); + border: 1px solid rgb(255 255 255 / 15%); + border-radius: 6px; + padding: 4px 0; + min-width: 180px; + max-height: 260px; + overflow-y: auto; + z-index: 30; + box-shadow: 0 4px 16px rgb(0 0 0 / 50%); + } + + .dropdown button { + display: block; + width: 100%; + text-align: left; + padding: 5px 12px; + white-space: nowrap; + color: rgb(255 255 255 / 80%); + font-size: 11px; + border-radius: 0; + } + + .dropdown button:hover { + background: rgb(255 255 255 / 10%); + color: #fff; + text-decoration: none; + } + `; + + static properties = { + root: { type: Object }, + stack: { type: Array }, + siblings: { type: Array }, + _openDropdown: { state: true } + }; + + constructor() { + super(); + this.root = null; + this.stack = []; + this.siblings = []; + this._openDropdown = null; + } + + connectedCallback() { + super.connectedCallback(); + document.addEventListener("click", this.#handleDocumentClick); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener("click", this.#handleDocumentClick); + } + + updated() { + this.hidden = this.stack.length === 0 || this.root === null; + } + + #handleDocumentClick = () => { + if (this._openDropdown !== null) { + this._openDropdown = null; + } + }; + + #handleReset() { + this.dispatchEvent(new CustomEvent(EVENTS.DRILL_RESET, { + bubbles: true, + composed: true + })); + } + + #handleBack(index) { + this.dispatchEvent(new CustomEvent(EVENTS.DRILL_BACK, { + detail: { index }, + bubbles: true, + composed: true + })); + } + + #toggleDropdown(index, event) { + event.stopPropagation(); + + this._openDropdown = this._openDropdown === index ? null : index; + } + + #handleSiblingClick(stackIndex, nodeId, event) { + event.stopPropagation(); + + this._openDropdown = null; + this.dispatchEvent(new CustomEvent(EVENTS.DRILL_SWITCH, { + detail: { stackIndex, nodeId }, + bubbles: true, + composed: true + })); + } + + render() { + if (this.stack.length === 0 || this.root === null) { + return nothing; + } + + return html` + + ${this.stack.map((entry, stackIndex) => { + const siblingList = this.siblings?.[stackIndex] ?? []; + const hasSiblings = siblingList.length > 0; + + return html` + + ${hasSiblings + ? html` + + ${this._openDropdown === stackIndex ? html` + + ` : nothing} + ` + : html`` + } + + ${stackIndex === this.stack.length - 1 + ? html`${entry.name}@${entry.version}` + : html`` + } + `; + })} + `; + } +} + +customElements.define("drill-breadcrumb", DrillBreadcrumb); diff --git a/public/core/events.js b/public/core/events.js index 43b5e71e..c1f7c421 100644 --- a/public/core/events.js +++ b/public/core/events.js @@ -10,5 +10,8 @@ export const EVENTS = { MODAL_OPENED: "modal-opened", NETWORK_VIEW_HID: "network-view-hid", NETWORK_VIEW_SHOWED: "network-view-showed", - SEARCH_COMMAND_INIT: "search-command-init" + SEARCH_COMMAND_INIT: "search-command-init", + DRILL_RESET: "drill-reset", + DRILL_BACK: "drill-back", + DRILL_SWITCH: "drill-switch" }; diff --git a/public/main.js b/public/main.js index 64cf442b..6ffc12f0 100644 --- a/public/main.js +++ b/public/main.js @@ -13,6 +13,7 @@ import "./components/search-command/search-command.js"; import { Settings } from "./components/views/settings/settings.js"; import { HomeView } from "./components/views/home/home.js"; import "./components/views/search/search.js"; +import "./components/drill-breadcrumb/drill-breadcrumb.js"; import { NetworkNavigation } from "./core/network-navigation.js"; import { i18n } from "./core/i18n.js"; import { initSearchNav } from "./core/search-nav.js"; @@ -24,7 +25,9 @@ let secureDataSet; let nsn; let homeView; let searchview; +let drillBreadcrumb; let packageInfoOpened = false; +const drillStack = []; document.addEventListener("DOMContentLoaded", async() => { searchview = document.querySelector("search-view"); @@ -39,6 +42,17 @@ document.addEventListener("DOMContentLoaded", async() => { // update searchview after window.i18n is set searchview.requestUpdate(); + drillBreadcrumb = document.querySelector("drill-breadcrumb"); + drillBreadcrumb.addEventListener(EVENTS.DRILL_RESET, resetDrill); + drillBreadcrumb.addEventListener(EVENTS.DRILL_BACK, function handleDrillBack(event) { + drillBackTo(event.detail.index); + }); + drillBreadcrumb.addEventListener(EVENTS.DRILL_SWITCH, function handleDrillSwitch(event) { + const { stackIndex, nodeId } = event.detail; + drillStack.length = stackIndex; + drillInto(nodeId); + }); + await init(); window.dispatchEvent( new CustomEvent(EVENTS.SETTINGS_SAVED, { @@ -122,6 +136,107 @@ function dispatchSearchCommandInit() { window.dispatchEvent(event); } +function computeSiblings(parentId, excludeId) { + const seen = new Set(); + const result = []; + + for (const edge of secureDataSet.rawEdgesData) { + if (edge.to === parentId && edge.from !== excludeId && !seen.has(edge.from)) { + seen.add(edge.from); + + const entry = secureDataSet.linker.get(edge.from); + result.push({ + nodeId: edge.from, + name: entry.name, + version: entry.version + }); + } + } + + return result.sort((nodeA, nodeB) => nodeA.name.localeCompare(nodeB.name)); +} + +function computeDrillSubtree(rootNodeId) { + const subtreeIds = new Set([rootNodeId]); + const queue = [rootNodeId]; + + while (queue.length > 0) { + const current = queue.shift(); + for (const edge of secureDataSet.rawEdgesData) { + if (edge.to === current && !subtreeIds.has(edge.from)) { + subtreeIds.add(edge.from); + queue.push(edge.from); + } + } + } + + return subtreeIds; +} + +function applyDrill(nodeId) { + const subtreeIds = computeDrillSubtree(nodeId); + const updates = [...secureDataSet.linker.keys()].map((id) => { + return { + id, + hidden: !subtreeIds.has(id) + }; + }); + nsn.nodes.update(updates); + nsn.network.unselectAll(); + updateDrillBreadcrumb(); + PackageInfo.close(); + nsn.neighbourHighlight({ nodes: [nodeId], edges: [] }); +} + +function drillInto(nodeId) { + const currentRoot = drillStack.length === 0 ? 0 : drillStack.at(-1); + if (nodeId === currentRoot) { + return; + } + + drillStack.push(nodeId); + applyDrill(nodeId); +} + +function drillBackTo(stackIndex) { + drillStack.length = stackIndex + 1; + applyDrill(drillStack[stackIndex]); +} + +function resetDrill() { + drillStack.length = 0; + const updates = [...secureDataSet.linker.keys()].map((id) => { + return { + id, + hidden: false + }; + }); + nsn.nodes.update(updates); + updateDrillBreadcrumb(); + PackageInfo.close(); +} + +function updateDrillBreadcrumb() { + const rootEntry = secureDataSet.linker.get(0); + drillBreadcrumb.root = { + name: rootEntry.name, + version: rootEntry.version + }; + drillBreadcrumb.stack = drillStack.map((nodeId) => { + const entry = secureDataSet.linker.get(nodeId); + + return { + name: entry.name, + version: entry.version + }; + }); + drillBreadcrumb.siblings = drillStack.map((nodeId, index) => { + const parentId = index === 0 ? 0 : drillStack[index - 1]; + + return computeSiblings(parentId, nodeId); + }); +} + async function init(options = {}) { const { navigateToNetworkView = false } = options; @@ -158,7 +273,22 @@ async function init(options = {}) { packageInfoOpened = false; }); - nsn.network.on("click", updateShowInfoMenu); + nsn.network.on("click", (params) => { + const srcEvent = params.event?.srcEvent; + const isDrillClick = srcEvent?.ctrlKey || srcEvent?.metaKey; + + if (isDrillClick && params.nodes.length > 0) { + const nodeId = Number(params.nodes[0]); + drillInto(nodeId); + + return; + } + + updateShowInfoMenu(params); + }); + + drillStack.length = 0; + updateDrillBreadcrumb(); const networkNavigation = new NetworkNavigation(secureDataSet, nsn); window.networkNav = networkNavigation; diff --git a/views/index.html b/views/index.html index 6404489c..b16cb4d4 100644 --- a/views/index.html +++ b/views/index.html @@ -80,6 +80,7 @@

[[=z.token('please_wait')]]

+

[[=z.token('network.unlocked')]]

diff --git a/workspaces/vis-network/src/network.ts b/workspaces/vis-network/src/network.ts index c92c626a..d18cf226 100644 --- a/workspaces/vis-network/src/network.ts +++ b/workspaces/vis-network/src/network.ts @@ -75,6 +75,10 @@ interface NetworkOptions { interface NetworkClickParams { nodes: IdType[]; edges: IdType[]; + // see http://hammerjs.github.io/api/#event-object + event?: { + srcEvent?: MouseEvent; + }; } type ColorPalette = (typeof CONSTANTS.COLORS)[keyof typeof CONSTANTS.COLORS]; @@ -149,7 +153,14 @@ export default class NodeSecureNetwork { this.isLoaded = true; this.network.stopSimulation(); - this.network.on("click", this.neighbourHighlight.bind(this)); + this.network.on("click", (params: NetworkClickParams) => { + const srcEvent = params.event?.srcEvent; + if (srcEvent?.ctrlKey || srcEvent?.metaKey) { + return; + } + + this.neighbourHighlight(params); + }); this.network.setOptions({ physics: false }); });