diff --git a/esbuild.config.mjs b/esbuild.config.mjs index a5de8b8..2fae0d3 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -33,7 +33,7 @@ const context = await esbuild.context({ "@lezer/lr", ...builtins], format: "cjs", - target: "es2018", + target: "es2022", logLevel: "info", sourcemap: prod ? false : "inline", treeShaking: true, diff --git a/src/settings.ts b/src/settings.ts index ff85530..72908fd 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -44,6 +44,14 @@ export class Graph3DSettingsTab extends PluginSettingTab { this.triggerUpdate({ redrawData: true, useCache: true }); }, 500, true))); + new Setting(containerEl).setName('Show neighboring nodes').setDesc('Show nodes that are linked to the search/filter results.') + .addToggle(toggle => toggle.setValue(this.plugin.settings.showNeighboringNodes) + .onChange(async (value) => { + this.plugin.settings.showNeighboringNodes = value; + await this.plugin.saveSettings(); + this.triggerUpdate({ redrawData: true, useCache: true }); + })); + containerEl.createEl('p', { text: 'Use the filters below to limit the number of nodes in the graph. Filters are applied in order.', cls: 'setting-item-description' }); this.plugin.settings.filters.forEach((filter, index) => { @@ -158,6 +166,19 @@ export class Graph3DSettingsTab extends PluginSettingTab { } new Setting(containerEl).setName('Appearance').setHeading(); + new Setting(containerEl).setName('Performance Mode').setDesc('Lowers geometry detail, disables link curvature and labels for better performance.') + .addToggle(toggle => toggle.setValue(this.plugin.settings.performanceMode) + .onChange(async (value) => { + this.plugin.settings.performanceMode = value; + await this.plugin.saveSettings(); + this.plugin.app.workspace.getLeavesOfType(VIEW_TYPE_3D_GRAPH).forEach(leaf => { + if (leaf.view instanceof Graph3DView) { + leaf.view.clearResourceCaches(); + } + }); + this.triggerUpdate({ updateDisplay: true }); + })); + new Setting(containerEl).setName('Node shape').addDropdown(dd => dd.addOptions(NodeShape).setValue(this.plugin.settings.nodeShape) .onChange(async(value: string) => {this.plugin.settings.nodeShape = value as NodeShape; await this.plugin.saveSettings(); this.triggerUpdate({updateDisplay: true})})); new Setting(containerEl).setName('Tag shape').addDropdown(dd => dd.addOptions(NodeShape).setValue(this.plugin.settings.tagShape) diff --git a/src/types.ts b/src/types.ts index 23543be..49f4a91 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,6 +58,7 @@ export interface Graph3DPluginSettings { rotateSpeed: number; panSpeed: number; zoomSpeed: number; + performanceMode: boolean; // Forces centerForce: number; repelForce: number; @@ -68,6 +69,7 @@ export const DEFAULT_SETTINGS: Graph3DPluginSettings = { // Search searchQuery: '', showNeighboringNodes: true, + performanceMode: false, // Filters filters: [], showAttachments: false, diff --git a/src/view.ts b/src/view.ts index ae2d86d..288b001 100644 --- a/src/view.ts +++ b/src/view.ts @@ -49,6 +49,7 @@ export class Graph3DView extends ItemView { private graphContainer: HTMLDivElement; private messageEl: HTMLDivElement; + private counterEl: HTMLDivElement; private settingsPanel: HTMLDivElement; private settingsToggleButton: HTMLDivElement; @@ -98,11 +99,12 @@ export class Graph3DView extends ItemView { private getSharedGeometry(shape: NodeShape): THREE.BufferGeometry { let geometry = this.geometryCache.get(shape); if (!geometry) { + const perf = this.settings.performanceMode; switch (shape) { case NodeShape.Cube: geometry = new THREE.BoxGeometry(1, 1, 1); break; - case NodeShape.Pyramid: geometry = new THREE.ConeGeometry(1 / 1.5, 1, 4); break; + case NodeShape.Pyramid: geometry = new THREE.ConeGeometry(1 / 1.5, 1, perf ? 3 : 4); break; case NodeShape.Tetrahedron: geometry = new THREE.TetrahedronGeometry(1 / 1.2); break; - default: geometry = new THREE.SphereGeometry(0.5); + default: geometry = new THREE.SphereGeometry(0.5, perf ? 8 : 16, perf ? 6 : 12); } this.geometryCache.set(shape, geometry); } @@ -122,7 +124,7 @@ export class Graph3DView extends ItemView { return material; } - private clearResourceCaches() { + public clearResourceCaches() { this.geometryCache.forEach(g => g.dispose()); this.geometryCache.clear(); this.materialCache.forEach(m => m.dispose()); @@ -141,16 +143,19 @@ export class Graph3DView extends ItemView { this.graphContainer = viewWrapper.createEl('div', { cls: 'graph-3d-container', attr: { tabindex: '0' } }); // Make it focusable this.messageEl = viewWrapper.createEl('div', { cls: 'graph-3d-message' }); + this.counterEl = viewWrapper.createEl('div', { cls: 'graph-3d-counter' }); this.addLocalControls(); this.initializeGraph(); - this.resizeObserver = new ResizeObserver(() => { + const debouncedResize = debounce(() => { if (this.graph && this.isGraphInitialized) { this.graph.width(this.graphContainer.offsetWidth); this.graph.height(this.graphContainer.offsetHeight); } - }); + }, 150, false); + + this.resizeObserver = new ResizeObserver(() => debouncedResize()); this.resizeObserver.observe(this.graphContainer); // Scoped event listeners @@ -215,6 +220,16 @@ export class Graph3DView extends ItemView { await this.plugin.saveSettings(); this.updateData({ useCache: true, reheat: false }); }, 500, true))); + new Setting(container) + .setName('Show neighboring nodes') + .setDesc('Show nodes linked to search/filter results.') + .addToggle(toggle => toggle + .setValue(this.settings.showNeighboringNodes) + .onChange(async (value) => { + this.settings.showNeighboringNodes = value; + await this.plugin.saveSettings(); + this.updateData({ useCache: true, reheat: false }); + })); } private renderAdvancedFilters(container: HTMLElement) { @@ -345,6 +360,23 @@ export class Graph3DView extends ItemView { private renderAppearanceSettings(container: HTMLElement) { new Setting(container).setHeading().setName('Appearance'); + new Setting(container) + .setName('Performance Mode') + .setDesc('Lowers geometry detail, disables link curvature and labels for better performance.') + .addToggle(toggle => toggle + .setValue(this.settings.performanceMode) + .onChange(async (value) => { + this.settings.performanceMode = value; + this.clearResourceCaches(); + await this.plugin.saveSettings(); + this.updateDisplay(); + if (value) { + this.graph.graphData().nodes.forEach((node: GraphNode) => this.removeNodeSprite(node)); + } else { + this.updateLabels(); + } + })); + const updateDisplayAndColors = async () => { await this.plugin.saveSettings(); this.updateDisplay(); @@ -712,6 +744,11 @@ export class Graph3DView extends ItemView { this.updateColors(); this.updateControls(); + if (this.counterEl) { + this.counterEl.setText(`${newData.nodes.length} nodes ยท ${newData.links.length} links`); + this.counterEl.addClass('is-visible'); + } + if (isFirstLoad || reheat) { this.graph.d3AlphaDecay(0.0228); this.graph.d3VelocityDecay(0.4); @@ -731,6 +768,10 @@ export class Graph3DView extends ItemView { this.graph.backgroundColor(bgColor); this.messageEl.setText("No search results or filters matched."); this.messageEl.addClass('is-visible'); + if (this.counterEl) { + this.counterEl.setText(''); + this.counterEl.removeClass('is-visible'); + } this.graph.pauseAnimation(); } } catch (error) { @@ -886,7 +927,11 @@ export class Graph3DView extends ItemView { } } } else { - if (node.name.toLowerCase().includes(query) || (node.lowerCaseContent && node.lowerCaseContent.includes(query))) { + let nodeContentLower = ''; + if (node.type === NodeType.File) { + nodeContentLower = this.fileContentCache.get(node.id)?.lowerCaseContent || ''; + } + if (node.name.toLowerCase().includes(query) || nodeContentLower.includes(query)) { return group.color; } } @@ -913,7 +958,7 @@ export class Graph3DView extends ItemView { // These are now updated dynamically without a full redraw this.graph .linkWidth((link: GraphLink) => this.highlightedLinks.has(link) ? (this.settings.linkThickness * 2) : this.settings.linkThickness) - .linkDirectionalParticles((link: GraphLink) => this.highlightedLinks.has(link) ? 4 : 0) + .linkDirectionalParticles((link: GraphLink) => (this.highlightedLinks.has(link) && !this.settings.performanceMode) ? 4 : 0) .linkDirectionalParticleWidth(2); } @@ -945,17 +990,6 @@ export class Graph3DView extends ItemView { this.nodeMeshes.set(node, mesh); group.add(mesh); - if (this.settings.showNodeLabels) { - const sprite = new SpriteText(node.name); - const isDarkMode = document.body.classList.contains('theme-dark'); - sprite.color = isDarkMode ? this.settings.labelTextColorDark : this.settings.labelTextColorLight; - sprite.backgroundColor = this.hexToRgba(this.settings.labelBackgroundColor, this.settings.labelBackgroundOpacity); - sprite.textHeight = this.settings.labelTextSize; - sprite.position.y = s / 2 + 2; - this.nodeSprites.set(node, sprite); - group.add(sprite); - } - return group; } @@ -991,7 +1025,7 @@ export class Graph3DView extends ItemView { } private updateLabels() { - if (!this.isGraphInitialized || !this.settings.showNodeLabels) return; + if (!this.isGraphInitialized || !this.settings.showNodeLabels || this.settings.performanceMode) return; const camera = this.graph.camera(); const nodes = this.graph.graphData().nodes; @@ -1012,14 +1046,11 @@ export class Graph3DView extends ItemView { : []; nodes.forEach((node: GraphNode) => { - const sprite = this.nodeSprites.get(node); - if (!sprite) return; - if (this.settings.showLabelsOnHoverOnly) { const isHovered = this.hoveredNode && this.hoveredNode.id === node.id; const isHighlighted = this.highlightedNodes.has(node.id); if (!isHovered && !isHighlighted) { - sprite.visible = false; + this.removeNodeSprite(node); return; } } @@ -1027,12 +1058,12 @@ export class Graph3DView extends ItemView { if (node.__threeObj) { node.__threeObj.getWorldPosition(this.reusableNodePosition); } else { - sprite.visible = false; + this.removeNodeSprite(node); return; } if (!this.frustum.containsPoint(this.reusableNodePosition)) { - sprite.visible = false; + this.removeNodeSprite(node); return; } @@ -1062,11 +1093,57 @@ export class Graph3DView extends ItemView { } } - (sprite.material as THREE.SpriteMaterial).opacity = opacity; - sprite.visible = opacity > 0.01; + if (opacity > 0.01) { + let sprite = this.nodeSprites.get(node); + if (!sprite) { + sprite = this.createNodeSprite(node); + } + if (sprite && sprite.material) { + (sprite.material as THREE.SpriteMaterial).opacity = opacity; + sprite.visible = true; + } + } else { + this.removeNodeSprite(node); + } }); } + private createNodeSprite(node: GraphNode): SpriteText { + const sprite = new SpriteText(node.name); + const isDarkMode = document.body.classList.contains('theme-dark'); + sprite.color = isDarkMode ? this.settings.labelTextColorDark : this.settings.labelTextColorLight; + sprite.backgroundColor = this.hexToRgba(this.settings.labelBackgroundColor, this.settings.labelBackgroundOpacity); + sprite.textHeight = this.settings.labelTextSize; + + let size: number; + switch (node.type) { + case NodeType.Tag: size = this.settings.tagNodeSize; break; + case NodeType.Attachment: size = this.settings.attachmentNodeSize; break; + default: size = this.settings.nodeSize; + } + const s = size * 1.5; + sprite.position.y = s / 2 + 2; + + this.nodeSprites.set(node, sprite); + if (node.__threeObj) { + node.__threeObj.add(sprite); + } + return sprite; + } + + private removeNodeSprite(node: GraphNode) { + const sprite = this.nodeSprites.get(node); + if (sprite) { + sprite.parent?.remove(sprite); + sprite.geometry?.dispose(); + if (sprite.material) { + sprite.material.map?.dispose(); + sprite.material.dispose(); + } + this.nodeSprites.delete(node); + } + } + private handleNodeClick(node: GraphNode, event?: MouseEvent) { if (!node) return; @@ -1183,6 +1260,7 @@ export class Graph3DView extends ItemView { } private getLinkCurvature(link: ProcessedGraphLink) { + if (this.settings.performanceMode) return 0; const sourceId = typeof link.source === 'object' ? (link.source as GraphNode).id : (link.source as string); const targetId = typeof link.target === 'object' ? (link.target as GraphNode).id : (link.target as string); const key = `${sourceId}->${targetId}`; @@ -1326,14 +1404,18 @@ export class Graph3DView extends ItemView { if (!allTags.has(tagName)) { allTags.set(tagName, { id: tagId, name: `#${tagName}`, type: NodeType.Tag }); } - allLinks.push({ source: node.id, target: tagId }); + const linkKey = `${node.id}->${tagId}`; + if (!existingLinks.has(linkKey)) { + allLinks.push({ source: node.id, target: tagId }); + existingLinks.add(linkKey); + } }); } }); allTags.forEach((tagNode, tagName) => allNodesMap.set(tagNode.id, tagNode)); } - let finalNodes = Array.from(allNodesMap.values()); + let matchedNodes = Array.from(allNodesMap.values()); // Advanced Filtering Logic const activeFilters = filters.filter(f => f.enabled && f.value.trim() !== ''); @@ -1341,28 +1423,47 @@ export class Graph3DView extends ItemView { if (activeFilters.length > 0) { const nodesToKeep = new Set(); activeFilters.forEach(filter => { - finalNodes.forEach(node => { + matchedNodes.forEach(node => { if (this.matchesFilter(node, filter)) { nodesToKeep.add(node); } }); }); - finalNodes = Array.from(nodesToKeep); + matchedNodes = Array.from(nodesToKeep); } - if (searchQuery) { const lowerCaseFilter = searchQuery.toLowerCase(); - finalNodes = finalNodes.filter(node => { - const nodeContentLower = node.lowerCaseContent || ''; + matchedNodes = matchedNodes.filter(node => { + let nodeContentLower = ''; + if (node.type === NodeType.File) { + nodeContentLower = this.fileContentCache.get(node.id)?.lowerCaseContent || ''; + } return node.name.toLowerCase().includes(lowerCaseFilter) || (node.type !== NodeType.Tag && node.id.toLowerCase().includes(lowerCaseFilter)) || nodeContentLower.includes(lowerCaseFilter); }); } - const finalNodeIds = new Set(finalNodes.map(n => n.id)); - let finalLinks = allLinks.filter(link => finalNodeIds.has(link.source) && finalNodeIds.has(link.target)); + let finalNodes: GraphNode[]; + if (showNeighboringNodes && (activeFilters.length > 0 || searchQuery)) { + const matchedIds = new Set(matchedNodes.map(n => n.id)); + const neighborIds = new Set(); + + allLinks.forEach(link => { + if (matchedIds.has(link.source)) { + neighborIds.add(link.target); + } else if (matchedIds.has(link.target)) { + neighborIds.add(link.source); + } + }); + + finalNodes = Array.from(allNodesMap.values()).filter(node => + matchedIds.has(node.id) || neighborIds.has(node.id) + ); + } else { + finalNodes = matchedNodes; + } let nodesToShow = finalNodes.filter(node => { if (node.type === NodeType.Tag) return showTags; @@ -1370,8 +1471,8 @@ export class Graph3DView extends ItemView { return true; }); - let nodesToShowIds = new Set(nodesToShow.map(n => n.id)); - let linksToShow = finalLinks.filter(link => nodesToShowIds.has(link.source) && nodesToShowIds.has(link.target)); + const nodesToShowIds = new Set(nodesToShow.map(n => n.id)); + const linksToShow = allLinks.filter(link => nodesToShowIds.has(link.source) && nodesToShowIds.has(link.target)); if (hideOrphans) { const linkedNodeIds = new Set(); @@ -1380,9 +1481,6 @@ export class Graph3DView extends ItemView { linkedNodeIds.add(link.target); }); nodesToShow = nodesToShow.filter(node => linkedNodeIds.has(node.id)); - - const visibleNodeIds = new Set(nodesToShow.map(n => n.id)); - linksToShow = linksToShow.filter(l => visibleNodeIds.has(l.source) && visibleNodeIds.has(l.target)); } return { nodes: nodesToShow, links: linksToShow }; @@ -1393,13 +1491,7 @@ export class Graph3DView extends ItemView { this.nodeMeshes.delete(node); } - const sprite = this.nodeSprites.get(node); - if (sprite) { - sprite.parent?.remove(sprite); - sprite.geometry?.dispose(); - sprite.material?.dispose(); - this.nodeSprites.delete(node); - } + this.removeNodeSprite(node); if (options.cleanGroup && node.__threeObj) { node.__threeObj.parent?.remove(node.__threeObj); @@ -1426,6 +1518,9 @@ export class Graph3DView extends ItemView { if (this.messageEl) { this.messageEl.remove(); } + if (this.counterEl) { + this.counterEl.remove(); + } this.clearResourceCaches(); this.colorCache.clear(); this.processedNodes.clear(); diff --git a/styles.css b/styles.css index 274a07b..f57ed06 100644 --- a/styles.css +++ b/styles.css @@ -83,3 +83,22 @@ .graph-3d-settings-panel .setting-item:first-child { border-top: none; } + +.graph-3d-counter { + position: absolute; + bottom: 10px; + left: 10px; + background-color: var(--background-secondary-alt); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + padding: 4px 8px; + font-size: var(--font-xs); + color: var(--text-muted); + z-index: 10; + pointer-events: none; + display: none; +} + +.graph-3d-counter.is-visible { + display: block; +}