├── .npmrc ├── .eslintignore ├── assets ├── hero.png ├── output.gif └── preview.mp4 ├── .editorconfig ├── versions.json ├── manifest.json ├── .gitignore ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── package.json ├── LICENSE ├── esbuild.config.mjs ├── styles.css ├── main.ts ├── src ├── types.ts ├── settings.ts └── view.ts └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /assets/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apoo711/obsidian-3d-graph/HEAD/assets/hero.png -------------------------------------------------------------------------------- /assets/output.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apoo711/obsidian-3d-graph/HEAD/assets/output.gif -------------------------------------------------------------------------------- /assets/preview.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apoo711/obsidian-3d-graph/HEAD/assets/preview.mp4 -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "2.2.2": "1.5.0", 3 | "2.2.1": "1.5.0", 4 | "2.2.0": "1.5.0", 5 | "2.1.3": "1.5.0", 6 | "2.1.2": "1.5.0", 7 | "2.1.1": "1.5.0", 8 | "2.1.0": "1.5.0", 9 | "2.0.1": "1.5.0", 10 | "2.0.0": "1.5.0", 11 | "1.0.0": "1.5.0", 12 | "2.2.3": "1.5.0", 13 | "2.3.0": "1.5.0", 14 | "2.4.0": "1.5.0", 15 | "2.4.1": "1.5.0" 16 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "new-3d-graph", 3 | "name": "New 3D Graph", 4 | "version": "2.4.1", 5 | "minAppVersion": "1.5.0", 6 | "description": "Visualize your vault in 3D with a powerful, highly customizable, and filterable graph.", 7 | "author": "Aryan Gupta", 8 | "authorUrl": "https://aryan-gupta.is-a.dev", 9 | "isDesktopOnly": true 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Use Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: "18.x" 20 | 21 | - name: Build plugin 22 | run: | 23 | npm install 24 | npm run build 25 | 26 | - name: Create release 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | run: | 30 | tag="${GITHUB_REF#refs/tags/}" 31 | 32 | gh release create "$tag" \ 33 | --title="$tag" \ 34 | --draft \ 35 | main.js manifest.json styles.css -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "3D Graph Plugin", 3 | "version": "2.4.1", 4 | "description": "Visualize your vault in 3D with a powerful, highly customizable, and filterable graph.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "Aryan Gupta", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@types/three": "^0.177.0", 17 | "@typescript-eslint/eslint-plugin": "5.29.0", 18 | "@typescript-eslint/parser": "5.29.0", 19 | "builtin-modules": "3.3.0", 20 | "esbuild": "0.17.3", 21 | "obsidian": "latest", 22 | "tslib": "2.4.0", 23 | "typescript": "4.7.4" 24 | }, 25 | "dependencies": { 26 | "3d-force-graph": "^1.77.0", 27 | "three": "^0.177.0", 28 | "three-spritetext": "^1.10.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Aryan Gupta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | minify: prod, 42 | }); 43 | 44 | if (prod) { 45 | await context.rebuild(); 46 | process.exit(0); 47 | } else { 48 | await context.watch(); 49 | } 50 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .graph-3d-view-content { 2 | position: relative; 3 | height: 100%; 4 | } 5 | 6 | .graph-3d-view-wrapper { 7 | position: relative; 8 | width: 100%; 9 | height: 100%; 10 | } 11 | 12 | .graph-3d-container { 13 | position: relative; 14 | width: 100%; 15 | height: 100%; 16 | } 17 | 18 | .graph-3d-message { 19 | position: absolute; 20 | top: 50%; 21 | left: 50%; 22 | transform: translate(-50%, -50%); 23 | color: var(--text-muted); 24 | display: none; 25 | z-index: 10; 26 | font-size: var(--font-ui-large); 27 | } 28 | 29 | .graph-3d-message.is-visible { 30 | display: block; 31 | } 32 | 33 | .graph-3d-controls-container { 34 | position: absolute; 35 | top: 10px; 36 | right: 10px; 37 | z-index: 10; 38 | } 39 | 40 | .graph-3d-settings-toggle { 41 | cursor: pointer; 42 | padding: 8px; 43 | background-color: var(--background-secondary-alt); 44 | border: 1px solid var(--background-modifier-border); 45 | border-radius: 6px; 46 | display: flex; 47 | align-items: center; 48 | justify-content: center; 49 | } 50 | 51 | .graph-3d-settings-toggle:hover { 52 | background-color: var(--background-modifier-hover); 53 | } 54 | 55 | .graph-3d-settings-panel { 56 | display: none; 57 | position: absolute; 58 | top: 45px; 59 | right: 0; 60 | width: 350px; 61 | max-height: 80vh; 62 | overflow-y: auto; 63 | background-color: var(--background-secondary); 64 | border: 1px solid var(--background-modifier-border); 65 | border-radius: 8px; 66 | padding: 16px; 67 | box-shadow: 0 4px 12px rgba(0,0,0,0.2); 68 | } 69 | 70 | .graph-3d-settings-panel.is-open { 71 | display: block; 72 | } 73 | 74 | .graph-3d-settings-panel .setting-item { 75 | border-top: 1px solid var(--background-modifier-border); 76 | padding: 10px 0; 77 | } 78 | 79 | .graph-3d-settings-panel .setting-item:first-child { 80 | border-top: none; 81 | } 82 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | // main.ts 2 | import { Plugin, debounce } from 'obsidian'; 3 | import { Graph3DPluginSettings, DEFAULT_SETTINGS } from './src/types'; 4 | import { Graph3DView, VIEW_TYPE_3D_GRAPH } from './src/view'; 5 | import { Graph3DSettingsTab } from './src/settings'; 6 | 7 | export default class Graph3DPlugin extends Plugin { 8 | settings: Graph3DPluginSettings; 9 | 10 | async onload() { 11 | await this.loadSettings(); 12 | this.registerView(VIEW_TYPE_3D_GRAPH, (leaf) => new Graph3DView(leaf, this)); 13 | this.addSettingTab(new Graph3DSettingsTab(this.app, this)); 14 | this.addRibbonIcon("network", "Open 3d graph", () => this.activateView()); 15 | 16 | this.addCommand({ 17 | id: 'open-3d-graph-view', 18 | name: 'Open 3d graph', 19 | callback: () => { 20 | this.activateView(); 21 | } 22 | }); 23 | 24 | // Debounced update for live changes in the vault 25 | const debouncedUpdate = debounce(() => this.triggerLiveUpdate(), 300, true); 26 | this.registerEvent(this.app.vault.on('create', debouncedUpdate)); 27 | this.registerEvent(this.app.vault.on('delete', debouncedUpdate)); 28 | this.registerEvent(this.app.vault.on('modify', debouncedUpdate)); 29 | this.registerEvent(this.app.metadataCache.on('resolve', debouncedUpdate)); 30 | } 31 | 32 | triggerLiveUpdate() { 33 | this.app.workspace.getLeavesOfType(VIEW_TYPE_3D_GRAPH).forEach(leaf => { 34 | if (leaf.view instanceof Graph3DView) { 35 | leaf.view.updateData(); 36 | } 37 | }); 38 | } 39 | 40 | onunload() {} 41 | 42 | async loadSettings() { 43 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 44 | } 45 | 46 | async saveSettings() { 47 | await this.saveData(this.settings); 48 | } 49 | 50 | async activateView() { 51 | const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_3D_GRAPH); 52 | if (leaves.length > 0) { 53 | this.app.workspace.revealLeaf(leaves[0]); 54 | return; 55 | } 56 | const leaf = this.app.workspace.getLeaf('tab'); 57 | await leaf.setViewState({ type: VIEW_TYPE_3D_GRAPH, active: true }); 58 | this.app.workspace.revealLeaf(leaf); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | export interface ColorGroup { 4 | query: string; 5 | color: string; 6 | } 7 | 8 | export interface Filter { 9 | type: 'path' | 'tag'; 10 | value: string; 11 | inverted: boolean; 12 | } 13 | 14 | export enum NodeShape { Sphere = 'Sphere', Cube = 'Cube', Pyramid = 'Pyramid', Tetrahedron = 'Tetrahedron' } 15 | 16 | export interface Graph3DPluginSettings { 17 | // Search 18 | searchQuery: string; 19 | showNeighboringNodes: boolean; 20 | // Filters 21 | filters: Filter[]; 22 | showAttachments: boolean; 23 | hideOrphans: boolean; 24 | showTags: boolean; 25 | // Groups 26 | groups: ColorGroup[]; 27 | // Display 28 | useThemeColors: boolean; 29 | colorNode: string; 30 | colorTag: string; 31 | colorAttachment: string; 32 | colorLink: string; 33 | colorHighlight: string; 34 | backgroundColor: string; 35 | // Appearance 36 | nodeSize: number; 37 | tagNodeSize: number; 38 | attachmentNodeSize: number; 39 | linkThickness: number; 40 | nodeShape: NodeShape; 41 | tagShape: NodeShape; 42 | attachmentShape: NodeShape; 43 | // Labels 44 | showNodeLabels: boolean; 45 | labelDistance: number; 46 | labelFadeThreshold: number; 47 | labelTextSize: number; 48 | labelTextColorLight: string; 49 | labelTextColorDark: string; 50 | labelBackgroundColor: string; 51 | labelBackgroundOpacity: number; 52 | labelOcclusion: boolean; 53 | // Interaction 54 | useKeyboardControls: boolean; 55 | keyboardMoveSpeed: number; 56 | zoomOnClick: boolean; 57 | rotateSpeed: number; 58 | panSpeed: number; 59 | zoomSpeed: number; 60 | // Forces 61 | centerForce: number; 62 | repelForce: number; 63 | linkForce: number; 64 | } 65 | 66 | export const DEFAULT_SETTINGS: Graph3DPluginSettings = { 67 | // Search 68 | searchQuery: '', 69 | showNeighboringNodes: true, 70 | // Filters 71 | filters: [], 72 | showAttachments: false, 73 | hideOrphans: false, 74 | showTags: false, 75 | // Groups 76 | groups: [], 77 | // Display 78 | useThemeColors: true, 79 | colorNode: '#2080F0', 80 | colorTag: '#9A49E8', 81 | colorAttachment: '#75B63A', 82 | colorLink: '#666666', 83 | colorHighlight: '#FFB800', 84 | backgroundColor: '#0E0E10', 85 | // Appearance 86 | nodeSize: 1.5, 87 | tagNodeSize: 1.0, 88 | attachmentNodeSize: 1.2, 89 | linkThickness: 1, 90 | nodeShape: NodeShape.Sphere, 91 | tagShape: NodeShape.Tetrahedron, 92 | attachmentShape: NodeShape.Cube, 93 | // Labels 94 | showNodeLabels: true, 95 | labelDistance: 150, 96 | labelFadeThreshold: 0.8, 97 | labelTextSize: 2.5, 98 | labelTextColorLight: '#000000', 99 | labelTextColorDark: '#ffffff', 100 | labelBackgroundColor: '#ffffff', 101 | labelBackgroundOpacity: 0.3, 102 | labelOcclusion: false, 103 | // Interaction 104 | useKeyboardControls: true, 105 | keyboardMoveSpeed: 2.0, 106 | zoomOnClick: true, 107 | rotateSpeed: 1.0, 108 | panSpeed: 1.0, 109 | zoomSpeed: 1.0, 110 | // Forces 111 | centerForce: 0.1, 112 | repelForce: 10, 113 | linkForce: 0.01, 114 | }; 115 | 116 | export enum NodeType { File, Tag, Attachment } 117 | 118 | export interface GraphNode { 119 | id: string; 120 | name: string; 121 | filename?: string; 122 | type: NodeType; 123 | tags?: string[]; 124 | content?: string; 125 | __threeObj?: THREE.Object3D; 126 | x?: number; 127 | y?: number; 128 | z?: number; 129 | // D3 Force properties for fixing node positions 130 | fx?: number; 131 | fy?: number; 132 | fz?: number; 133 | } 134 | 135 | export interface GraphLink { 136 | source: string | GraphNode; 137 | target: string | GraphNode; 138 | } 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 3D Graph for Obsidian 2 | ![hero](assets/hero.png) 3 | 4 | A plugin for Obsidian that provides a highly customizable 3D, force-directed graph view of your vault. This offers an alternative, immersive way to visualize and explore the connections between your notes. 5 | 6 | --- 7 | 8 | 💡 *Check out my blog post [here](https://aryan-gupta.is-a.dev/blog/2025/3d-graph-plugin/)* 9 | 10 | --- 11 | 12 | ## Why Choose This 3D Graph? 13 | While other 3D graph plugins exist, this one is built to offer the most **interactive and deeply customizable** experience for exploring your vault. 14 | 15 | - **Unparalleled Customization**: Go beyond the basics with granular control over your graph's appearance and physics. Independently customize the shape, size, and color for notes, attachments, and tags. Fine-tune the physics simulation with live-updating sliders to get the exact layout you want. 16 | 17 | - **A Truly Live Experience**: All settings—from colors and filters to physics—are applied instantly without needing to reload the view. This creates a fluid, interactive experience that lets you sculpt and analyze your graph in real-time. 18 | 19 | - **Powerful Filtering and Coloring**: Visually organize your vault with powerful tools. Use `path:`, `tag:`, and `file:` queries to create color-coded groups, just like in Obsidian's native graph. A powerful search bar also helps you find specific notes and focus on their connections. 20 | 21 | - **Modern & Maintained**: Built on a robust and performant tech stack, this plugin is actively maintained to ensure compatibility and introduce new features. 22 | 23 | ## Features 24 | 25 | * **Interactive 3D Canvas:** Pan, zoom, and rotate around your notes to explore their relationships from any angle. 26 | 27 | * **Node Interaction:** 28 | 29 | * **Single-click** on a node to focus the camera on it and highlight its immediate connections. 30 | 31 | * **Double-click** on a file or attachment node to open it in a new tab. 32 | 33 | * **Advanced Filtering & Search:** 34 | 35 | * **Live Search:** A powerful search bar to find specific notes and their neighbors. 36 | 37 | * **Live Filters:** Toggle visibility for attachments, tags, and orphan nodes on the fly. 38 | 39 | * **Deep Customization:** 40 | 41 | * **Color Groups:** Create rules to color-code your graph based on file paths (`path:`), tags (`tag:`), file names (`file:`), or content. 42 | 43 | * **Node Appearance:** Independently control the shape, size, and color for notes, attachments, and tags. 44 | 45 | * **Physics Engine:** Fine-tune the graph's layout with sliders for Center force, Repel force, and Link force. 46 | 47 | * **Stable & Performant:** 48 | 49 | * All settings update the graph instantly without requiring a reload. 50 | * Intelligently caches node positions for a smooth experience when updating data. 51 | 52 | ![My Video](assets/output.gif) 53 | 54 | *To watch the video in higher resolution click [here](https://github.com/Apoo711/obsidian-3d-graph/issues/6)* 55 | 56 | ## How to Install 57 | 58 | ### Recommended Method (from Community Plugins) 59 | 1. Open **Settings > Community plugins**. 60 | 61 | 2. Make sure "Restricted mode" is turned **off**. 62 | 63 | 3. Click **Browse** and search for "New 3D Graph". 64 | 65 | 4. Click **Install**. 66 | 67 | 5. Once installed, close the community plugins window and **Enable** the plugin. 68 | 69 | ### Beta Installation (using BRAT) 70 | For those who want the latest beta features: 71 | 72 | 1. Install the **BRAT** plugin from the Community Plugins browser. 73 | 74 | 2. Open the BRAT settings (`Settings` > `BRAT`). 75 | 76 | 3. In the "Beta Plugin List" section, click **Add Beta plugin**. 77 | 78 | 4. Use the repository path: `Apoo711/obsidian-3d-graph` 79 | 80 | 5. Enable the "3D Graph" plugin in the Community Plugins tab. 81 | 82 | Once enabled, you can open the 3D Graph from the ribbon icon on the left sidebar or by using the Command Palette (`Ctrl/Cmd + P` and typing "Open 3D Graph"). 83 | 84 | ## Settings 85 | 86 | You can configure the 3D Graph by going to `Settings` > `3D Graph Plugin`. All settings are applied live. 87 | 88 | * **Search:** Filter the graph by a search term. 89 | 90 | * **Filters:** Toggle visibility for `tags`, `attachments`, and `orphans`. 91 | 92 | * **Color Groups:** Set custom colors for nodes using `path:`, `tag:`, and `file:` queries. 93 | 94 | * **Display & Appearance:** Customize the shape, size, and color for every element in the graph. 95 | 96 | * **Forces:** Adjust the physics simulation to change the graph's layout and feel. 97 | 98 | ## Future Plans 99 | 100 | * **Performance Optimizations:** 101 | 102 | * Implement a "Local Graph" mode for massive vaults. 103 | 104 | * **UX Enhancements:** 105 | 106 | * Add more advanced query types for groups and search. 107 | 108 | ## Acknowledgements 109 | 110 | This plugin relies heavily on the fantastic [3d-force-graph](https://github.com/vasturiano/3d-force-graph) library for rendering and physics. 111 | 112 | Built with ❤️ for the Obsidian community. 113 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | // src/settings.ts 2 | import { App, PluginSettingTab, Setting, debounce } from 'obsidian'; 3 | import Graph3DPlugin from '../main'; 4 | import { NodeShape } from './types'; 5 | import { Graph3DView, VIEW_TYPE_3D_GRAPH } from './view'; 6 | 7 | export class Graph3DSettingsTab extends PluginSettingTab { 8 | plugin: Graph3DPlugin; 9 | constructor(app: App, plugin: Graph3DPlugin) { super(app, plugin); this.plugin = plugin; } 10 | 11 | private triggerUpdate(options: { redrawData?: boolean, useCache?: boolean, reheat?: boolean, updateDisplay?: boolean, updateControls?: boolean }) { 12 | this.plugin.app.workspace.getLeavesOfType(VIEW_TYPE_3D_GRAPH).forEach(leaf => { 13 | if (leaf.view instanceof Graph3DView) { 14 | if (leaf.view.isSettingsPanelOpen()) { 15 | leaf.view.renderSettingsPanel(); 16 | } 17 | 18 | if (options.redrawData) { 19 | leaf.view.updateData({ useCache: options.useCache, reheat: options.reheat }); 20 | } else if (options.updateDisplay) { 21 | leaf.view.updateDisplay(); 22 | leaf.view.updateColors(); 23 | } else if (options.updateControls) { 24 | leaf.view.updateControls(); 25 | } else { 26 | leaf.view.updateColors(); 27 | } 28 | } 29 | }); 30 | } 31 | 32 | display(): void { 33 | const { containerEl } = this; 34 | containerEl.empty(); 35 | 36 | new Setting(containerEl).setName('Filters').setHeading(); 37 | 38 | new Setting(containerEl).setName('Search term').setDesc('Only show notes containing this text.') 39 | .addText(text => text.setPlaceholder('Enter search term...') 40 | .setValue(this.plugin.settings.searchQuery) 41 | .onChange(debounce(async (value) => { 42 | this.plugin.settings.searchQuery = value.trim(); 43 | await this.plugin.saveSettings(); 44 | this.triggerUpdate({ redrawData: true, useCache: true }); 45 | }, 500, true))); 46 | 47 | 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' }); 48 | 49 | this.plugin.settings.filters.forEach((filter, index) => { 50 | const setting = new Setting(containerEl) 51 | .addDropdown(dropdown => dropdown 52 | .addOption('path', 'Path') 53 | .addOption('tag', 'Tag') 54 | .setValue(filter.type) 55 | .onChange(async (value: 'path' | 'tag') => { 56 | filter.type = value; 57 | await this.plugin.saveSettings(); 58 | this.triggerUpdate({ redrawData: true, useCache: true }); 59 | })) 60 | .addText(text => text 61 | .setPlaceholder('Enter filter value...') 62 | .setValue(filter.value) 63 | .onChange(debounce(async (value) => { 64 | filter.value = value; 65 | await this.plugin.saveSettings(); 66 | this.triggerUpdate({ redrawData: true, useCache: true }); 67 | }, 500, true))) 68 | .addToggle(toggle => toggle 69 | .setTooltip("Invert filter (NOT)") 70 | .setValue(filter.inverted) 71 | .onChange(async (value) => { 72 | filter.inverted = value; 73 | await this.plugin.saveSettings(); 74 | this.triggerUpdate({ redrawData: true, useCache: true }); 75 | })) 76 | .addExtraButton(button => button 77 | .setIcon('cross') 78 | .setTooltip('Remove filter') 79 | .onClick(async () => { 80 | this.plugin.settings.filters.splice(index, 1); 81 | await this.plugin.saveSettings(); 82 | this.display(); 83 | this.triggerUpdate({ redrawData: true, useCache: true }); 84 | })); 85 | }); 86 | 87 | new Setting(containerEl) 88 | .addButton(button => button 89 | .setButtonText('Add new filter') 90 | .onClick(async () => { 91 | this.plugin.settings.filters.push({ type: 'path', value: '', inverted: false }); 92 | await this.plugin.saveSettings(); 93 | this.display(); 94 | })); 95 | 96 | 97 | new Setting(containerEl).setName('General Filters').setHeading(); 98 | new Setting(containerEl).setName('Show tags').addToggle(toggle => toggle.setValue(this.plugin.settings.showTags) 99 | .onChange(async (value) => { this.plugin.settings.showTags = value; await this.plugin.saveSettings(); this.triggerUpdate({ redrawData: true, useCache: true }); })); 100 | new Setting(containerEl).setName('Show attachments').addToggle(toggle => toggle.setValue(this.plugin.settings.showAttachments) 101 | .onChange(async (value) => { this.plugin.settings.showAttachments = value; await this.plugin.saveSettings(); this.triggerUpdate({ redrawData: true, useCache: true }); })); 102 | new Setting(containerEl).setName('Hide orphans').addToggle(toggle => toggle.setValue(this.plugin.settings.hideOrphans) 103 | .onChange(async (value) => { this.plugin.settings.hideOrphans = value; await this.plugin.saveSettings(); this.triggerUpdate({ redrawData: true, useCache: true }); })); 104 | 105 | new Setting(containerEl).setName('Color Groups').setHeading(); 106 | containerEl.createEl('p', { text: 'Color nodes with custom rules. Use "path:", "tag:", "file:", or text match. Examples: path:folder, tag:#project, file:MyNote.md, file:*.pdf', cls: 'setting-item-description' }); 107 | 108 | this.plugin.settings.groups.forEach((group, index) => { 109 | new Setting(containerEl) 110 | .addText(text => text 111 | .setPlaceholder('path:, tag:, file:, or text') 112 | .setValue(group.query) 113 | .onChange(async (value) => { 114 | group.query = value; 115 | await this.plugin.saveSettings(); 116 | this.triggerUpdate({ updateDisplay: true }); 117 | })) 118 | .addColorPicker(color => color 119 | .setValue(group.color) 120 | .onChange(async (value) => { 121 | group.color = value; 122 | await this.plugin.saveSettings(); 123 | this.triggerUpdate({ updateDisplay: true }); 124 | })) 125 | .addExtraButton(button => button 126 | .setIcon('cross') 127 | .setTooltip('Remove group') 128 | .onClick(async () => { 129 | this.plugin.settings.groups.splice(index, 1); 130 | await this.plugin.saveSettings(); 131 | this.display(); 132 | this.triggerUpdate({ updateDisplay: true }); 133 | })); 134 | }); 135 | 136 | new Setting(containerEl) 137 | .addButton(button => button 138 | .setButtonText('Add new group') 139 | .onClick(async () => { 140 | this.plugin.settings.groups.push({ query: '', color: '#ffffff' }); 141 | await this.plugin.saveSettings(); 142 | this.display(); 143 | })); 144 | 145 | new Setting(containerEl).setName('Display').setHeading(); 146 | new Setting(containerEl).setName('Use theme colors').setDesc('Automatically use your current Obsidian theme colors for the graph.') 147 | .addToggle(toggle => toggle.setValue(this.plugin.settings.useThemeColors) 148 | .onChange(async (value) => {this.plugin.settings.useThemeColors = value; await this.plugin.saveSettings(); this.display(); this.triggerUpdate({ updateDisplay: true }); })); 149 | 150 | if (!this.plugin.settings.useThemeColors) { 151 | new Setting(containerEl).setName('Node color').addColorPicker(c => c.setValue(this.plugin.settings.colorNode).onChange(async (v) => { this.plugin.settings.colorNode = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 152 | new Setting(containerEl).setName('Tag color').addColorPicker(c => c.setValue(this.plugin.settings.colorTag).onChange(async (v) => { this.plugin.settings.colorTag = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 153 | new Setting(containerEl).setName('Attachment color').addColorPicker(c => c.setValue(this.plugin.settings.colorAttachment).onChange(async (v) => { this.plugin.settings.colorAttachment = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 154 | new Setting(containerEl).setName('Link color').addColorPicker(c => c.setValue(this.plugin.settings.colorLink).onChange(async (v) => { this.plugin.settings.colorLink = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 155 | new Setting(containerEl).setName('Highlight color').addColorPicker(c => c.setValue(this.plugin.settings.colorHighlight).onChange(async (v) => { this.plugin.settings.colorHighlight = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 156 | new Setting(containerEl).setName('Background color').addColorPicker(c => c.setValue(this.plugin.settings.backgroundColor).onChange(async (v) => { this.plugin.settings.backgroundColor = v; await this.plugin.saveSettings(); this.triggerUpdate({}); })); 157 | } 158 | 159 | new Setting(containerEl).setName('Appearance').setHeading(); 160 | new Setting(containerEl).setName('Node shape').addDropdown(dd => dd.addOptions(NodeShape).setValue(this.plugin.settings.nodeShape) 161 | .onChange(async(value: NodeShape) => {this.plugin.settings.nodeShape = value; await this.plugin.saveSettings(); this.triggerUpdate({updateDisplay: true})})); 162 | new Setting(containerEl).setName('Tag shape').addDropdown(dd => dd.addOptions(NodeShape).setValue(this.plugin.settings.tagShape) 163 | .onChange(async(value: NodeShape) => {this.plugin.settings.tagShape = value; await this.plugin.saveSettings(); this.triggerUpdate({updateDisplay: true})})); 164 | new Setting(containerEl).setName('Attachment shape').addDropdown(dd => dd.addOptions(NodeShape).setValue(this.plugin.settings.attachmentShape) 165 | .onChange(async(value: NodeShape) => {this.plugin.settings.attachmentShape = value; await this.plugin.saveSettings(); this.triggerUpdate({updateDisplay: true})})); 166 | new Setting(containerEl).setName('Node size').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.plugin.settings.nodeSize).setDynamicTooltip() 167 | .onChange(async (v) => { this.plugin.settings.nodeSize = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 168 | new Setting(containerEl).setName('Tag node size').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.plugin.settings.tagNodeSize).setDynamicTooltip() 169 | .onChange(async (v) => { this.plugin.settings.tagNodeSize = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 170 | new Setting(containerEl).setName('Attachment node size').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.plugin.settings.attachmentNodeSize).setDynamicTooltip() 171 | .onChange(async (v) => { this.plugin.settings.attachmentNodeSize = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 172 | new Setting(containerEl).setName('Link thickness').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.plugin.settings.linkThickness).setDynamicTooltip() 173 | .onChange(async (v) => { this.plugin.settings.linkThickness = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 174 | 175 | new Setting(containerEl).setName('Labels').setHeading(); 176 | new Setting(containerEl).setName('Show node labels').addToggle(toggle => toggle.setValue(this.plugin.settings.showNodeLabels) 177 | .onChange(async (value) => { this.plugin.settings.showNodeLabels = value; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 178 | new Setting(containerEl).setName('Label distance').addSlider(s => s.setLimits(50, 500, 10).setValue(this.plugin.settings.labelDistance).setDynamicTooltip() 179 | .onChange(async (v) => { this.plugin.settings.labelDistance = v; await this.plugin.saveSettings(); })); 180 | new Setting(containerEl).setName('Label fade threshold').addSlider(s => s.setLimits(0.1, 1, 0.1).setValue(this.plugin.settings.labelFadeThreshold).setDynamicTooltip() 181 | .onChange(async (v) => { this.plugin.settings.labelFadeThreshold = v; await this.plugin.saveSettings(); })); 182 | new Setting(containerEl).setName('Label text size').addSlider(s => s.setLimits(1, 10, 0.5).setValue(this.plugin.settings.labelTextSize).setDynamicTooltip() 183 | .onChange(async (v) => { this.plugin.settings.labelTextSize = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 184 | 185 | new Setting(containerEl).setName('Label Text Color (Dark Theme)').addColorPicker(c => c.setValue(this.plugin.settings.labelTextColorDark).onChange(async (v) => { this.plugin.settings.labelTextColorDark = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 186 | new Setting(containerEl).setName('Label Text Color (Light Theme)').addColorPicker(c => c.setValue(this.plugin.settings.labelTextColorLight).onChange(async (v) => { this.plugin.settings.labelTextColorLight = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 187 | new Setting(containerEl).setName('Label Background Color').addColorPicker(c => c.setValue(this.plugin.settings.labelBackgroundColor).onChange(async (v) => { this.plugin.settings.labelBackgroundColor = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 188 | new Setting(containerEl).setName('Label Background Opacity').addSlider(s => s.setLimits(0, 1, 0.1).setValue(this.plugin.settings.labelBackgroundOpacity).setDynamicTooltip() 189 | .onChange(async (v) => { this.plugin.settings.labelBackgroundOpacity = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateDisplay: true }); })); 190 | 191 | new Setting(containerEl).setName('Prevent label occlusion').addToggle(toggle => toggle.setValue(this.plugin.settings.labelOcclusion) 192 | .onChange(async (value) => { this.plugin.settings.labelOcclusion = value; await this.plugin.saveSettings(); })); 193 | 194 | 195 | new Setting(containerEl).setName('Interaction').setHeading(); 196 | new Setting(containerEl).setName("Use Keyboard Controls (WASD)").setDesc("Enable game-like controls for camera movement.") 197 | .addToggle(toggle => toggle.setValue(this.plugin.settings.useKeyboardControls) 198 | .onChange(async (value) => { this.plugin.settings.useKeyboardControls = value; await this.plugin.saveSettings(); this.triggerUpdate({ updateControls: true }); })); 199 | new Setting(containerEl).setName('Keyboard move speed').addSlider(s => s.setLimits(0.1, 10, 0.1).setValue(this.plugin.settings.keyboardMoveSpeed).setDynamicTooltip() 200 | .onChange(async (v) => { this.plugin.settings.keyboardMoveSpeed = v; await this.plugin.saveSettings(); })); 201 | new Setting(containerEl).setName("Zoom on click").addToggle(toggle => toggle.setValue(this.plugin.settings.zoomOnClick) 202 | .onChange(async (value) => {this.plugin.settings.zoomOnClick = value; await this.plugin.saveSettings();})); 203 | new Setting(containerEl).setName('Rotation speed').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.plugin.settings.rotateSpeed).setDynamicTooltip() 204 | .onChange(async (v) => {this.plugin.settings.rotateSpeed = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateControls: true });})); 205 | new Setting(containerEl).setName('Pan speed').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.plugin.settings.panSpeed).setDynamicTooltip() 206 | .onChange(async (v) => {this.plugin.settings.panSpeed = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateControls: true });})); 207 | new Setting(containerEl).setName('Zoom speed').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.plugin.settings.zoomSpeed).setDynamicTooltip() 208 | .onChange(async (v) => {this.plugin.settings.zoomSpeed = v; await this.plugin.saveSettings(); this.triggerUpdate({ updateControls: true });})); 209 | 210 | new Setting(containerEl).setName('Forces').setHeading(); 211 | const forceSettingHandler = async (value: number, setting: 'centerForce' | 'repelForce' | 'linkForce') => { 212 | this.plugin.settings[setting] = value; 213 | await this.plugin.saveSettings(); 214 | this.triggerUpdate({ redrawData: true, useCache: false, reheat: true }); 215 | }; 216 | 217 | new Setting(containerEl).setName('Center force').addSlider(s => s.setLimits(0, 1, 0.01).setValue(this.plugin.settings.centerForce).setDynamicTooltip() 218 | .onChange(async (v) => { await forceSettingHandler(v, 'centerForce'); })); 219 | new Setting(containerEl).setName('Repel force').addSlider(s => s.setLimits(0, 20, 0.1).setValue(this.plugin.settings.repelForce).setDynamicTooltip() 220 | .onChange(async (v) => { await forceSettingHandler(v, 'repelForce'); })); 221 | new Setting(containerEl).setName('Link force').addSlider(s => s.setLimits(0, 0.1, 0.001).setValue(this.plugin.settings.linkForce).setDynamicTooltip() 222 | .onChange(async (v) => { await forceSettingHandler(v, 'linkForce'); })); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/view.ts: -------------------------------------------------------------------------------- 1 | // src/view.ts 2 | import { ItemView, WorkspaceLeaf, TFile, Setting, setIcon, debounce } from 'obsidian'; 3 | import ForceGraph3D from '3d-force-graph'; 4 | import * as THREE from 'three'; 5 | import SpriteText from 'three-spritetext'; 6 | import Graph3DPlugin from '../main'; 7 | import { Graph3DPluginSettings, GraphNode, GraphLink, NodeShape, NodeType, Filter } from './types'; 8 | 9 | export const VIEW_TYPE_3D_GRAPH = "3d-graph-view"; 10 | 11 | // Define a more specific type for links once they are processed by the graph engine 12 | interface ProcessedGraphLink { 13 | source: GraphNode; 14 | target: GraphNode; 15 | } 16 | 17 | export class Graph3DView extends ItemView { 18 | private graph: any; 19 | private plugin: Graph3DPlugin; 20 | private settings: Graph3DPluginSettings; 21 | private resizeObserver: ResizeObserver; 22 | private raycaster = new THREE.Raycaster(); 23 | 24 | private reusableNodePosition = new THREE.Vector3(); 25 | private reusableDirection = new THREE.Vector3(); 26 | private cachedOccluders: THREE.Mesh[] = []; 27 | private occludersCacheDirty = true; 28 | private readonly RAYCAST_CULL_DISTANCE = 800; 29 | 30 | private nodeMeshes = new WeakMap(); 31 | private nodeSprites = new WeakMap(); 32 | 33 | private highlightedNodes = new Set(); 34 | private highlightedLinks = new Set(); 35 | private selectedNode: string | null = null; 36 | private hoveredNode: GraphNode | null = null; 37 | 38 | private colorCache = new Map(); 39 | 40 | private graphContainer: HTMLDivElement; 41 | private messageEl: HTMLDivElement; 42 | private settingsPanel: HTMLDivElement; 43 | private settingsToggleButton: HTMLDivElement; 44 | 45 | private chargeForce: any; 46 | private centerForce: any; 47 | private linkForce: any; 48 | 49 | private clickTimeout: any = null; 50 | private isGraphInitialized = false; 51 | private isUpdating = false; 52 | private readonly CLICK_DELAY = 250; 53 | 54 | private lastLabelUpdateTime = 0; 55 | private readonly LABEL_UPDATE_INTERVAL = 100; 56 | 57 | // Keyboard controls state 58 | private pressedKeys = new Set(); 59 | 60 | constructor(leaf: WorkspaceLeaf, plugin: Graph3DPlugin) { 61 | super(leaf); 62 | this.plugin = plugin; 63 | this.settings = plugin.settings; 64 | } 65 | 66 | getViewType() { return VIEW_TYPE_3D_GRAPH; } 67 | getDisplayText() { return "3d graph"; } 68 | 69 | async onOpen() { 70 | const rootContainer = this.contentEl; 71 | rootContainer.empty(); 72 | rootContainer.addClass('graph-3d-view-content'); 73 | 74 | const viewWrapper = rootContainer.createEl('div', { cls: 'graph-3d-view-wrapper' }); 75 | 76 | this.graphContainer = viewWrapper.createEl('div', { cls: 'graph-3d-container', attr: { tabindex: '0' } }); // Make it focusable 77 | this.messageEl = viewWrapper.createEl('div', { cls: 'graph-3d-message' }); 78 | 79 | this.addLocalControls(); 80 | this.initializeGraph(); 81 | 82 | this.resizeObserver = new ResizeObserver(() => { 83 | if (this.graph && this.isGraphInitialized) { 84 | this.graph.width(this.graphContainer.offsetWidth); 85 | this.graph.height(this.graphContainer.offsetHeight); 86 | } 87 | }); 88 | this.resizeObserver.observe(this.graphContainer); 89 | 90 | // Scoped event listeners 91 | this.registerDomEvent(this.graphContainer, 'keydown', this.handleKeyDown.bind(this)); 92 | this.registerDomEvent(this.graphContainer, 'keyup', this.handleKeyUp.bind(this)); 93 | } 94 | 95 | private addLocalControls() { 96 | const controlsContainer = this.contentEl.createEl('div', { cls: 'graph-3d-controls-container' }); 97 | this.settingsToggleButton = controlsContainer.createEl('div', { cls: 'graph-3d-settings-toggle' }); 98 | setIcon(this.settingsToggleButton, 'settings'); 99 | this.settingsToggleButton.setAttribute('aria-label', 'Graph settings'); 100 | this.settingsPanel = controlsContainer.createEl('div', { cls: 'graph-3d-settings-panel' }); 101 | this.settingsToggleButton.addEventListener('click', () => { 102 | this.settingsPanel.classList.toggle('is-open'); 103 | }); 104 | this.renderSettingsPanel(); 105 | } 106 | 107 | public renderSettingsPanel() { 108 | this.settingsPanel.empty(); 109 | this.renderSearchSettings(this.settingsPanel); 110 | this.renderAdvancedFilters(this.settingsPanel); 111 | this.renderFilterSettings(this.settingsPanel); 112 | this.renderGroupSettings(this.settingsPanel); 113 | this.renderAppearanceSettings(this.settingsPanel); 114 | this.renderLabelSettings(this.settingsPanel); 115 | this.renderInteractionSettings(this.settingsPanel); 116 | this.renderForceSettings(this.settingsPanel); 117 | } 118 | 119 | public isSettingsPanelOpen(): boolean { 120 | return this.settingsPanel?.classList.contains('is-open'); 121 | } 122 | 123 | private renderSearchSettings(container: HTMLElement) { 124 | new Setting(container).setHeading().setName('Search'); 125 | new Setting(container) 126 | .setName('Search term') 127 | .addText(text => text 128 | .setValue(this.settings.searchQuery) 129 | .onChange(debounce(async (value) => { 130 | this.settings.searchQuery = value.trim(); 131 | await this.plugin.saveSettings(); 132 | this.updateData({ useCache: true, reheat: false }); 133 | }, 500, true))); 134 | } 135 | 136 | private renderAdvancedFilters(container: HTMLElement) { 137 | new Setting(container).setHeading().setName('Advanced Filters'); 138 | 139 | this.settings.filters.forEach((filter, index) => { 140 | const setting = new Setting(container) 141 | .addDropdown(dropdown => dropdown 142 | .addOption('path', 'Path') 143 | .addOption('tag', 'Tag') 144 | .setValue(filter.type) 145 | .onChange(async (value: 'path' | 'tag') => { 146 | filter.type = value; 147 | await this.plugin.saveSettings(); 148 | this.updateData({ useCache: true }); 149 | })) 150 | .addText(text => text 151 | .setPlaceholder('Enter filter value...') 152 | .setValue(filter.value) 153 | .onChange(debounce(async (value) => { 154 | filter.value = value; 155 | await this.plugin.saveSettings(); 156 | this.updateData({ useCache: true }); 157 | }, 500, true))) 158 | .addToggle(toggle => toggle 159 | .setTooltip("Invert filter (NOT)") 160 | .setValue(filter.inverted) 161 | .onChange(async (value) => { 162 | filter.inverted = value; 163 | await this.plugin.saveSettings(); 164 | this.updateData({ useCache: true }); 165 | })) 166 | .addExtraButton(button => button 167 | .setIcon('cross') 168 | .setTooltip('Remove filter') 169 | .onClick(async () => { 170 | this.settings.filters.splice(index, 1); 171 | await this.plugin.saveSettings(); 172 | this.renderSettingsPanel(); 173 | this.updateData({ useCache: true }); 174 | })); 175 | }); 176 | 177 | new Setting(container) 178 | .addButton(button => button 179 | .setButtonText('Add new filter') 180 | .onClick(async () => { 181 | this.settings.filters.push({ type: 'path', value: '', inverted: false }); 182 | await this.plugin.saveSettings(); 183 | this.renderSettingsPanel(); 184 | })); 185 | } 186 | 187 | private renderFilterSettings(container: HTMLElement) { 188 | new Setting(container).setHeading().setName('General Filters'); 189 | 190 | new Setting(container).setName('Show tags').addToggle(toggle => toggle 191 | .setValue(this.settings.showTags) 192 | .onChange(async (value) => { 193 | this.settings.showTags = value; 194 | await this.plugin.saveSettings(); 195 | this.updateData({ useCache: true, reheat: false }); 196 | })); 197 | 198 | new Setting(container).setName('Show attachments').addToggle(toggle => toggle 199 | .setValue(this.settings.showAttachments) 200 | .onChange(async (value) => { 201 | this.settings.showAttachments = value; 202 | await this.plugin.saveSettings(); 203 | this.updateData({ useCache: true, reheat: false }); 204 | })); 205 | 206 | new Setting(container).setName('Hide orphans').addToggle(toggle => toggle 207 | .setValue(this.settings.hideOrphans) 208 | .onChange(async (value) => { 209 | this.settings.hideOrphans = value; 210 | await this.plugin.saveSettings(); 211 | this.updateData({ useCache: true, reheat: false }); 212 | })); 213 | } 214 | 215 | private renderGroupSettings(container: HTMLElement) { 216 | const groupContainer = container.createDiv(); 217 | const render = () => { 218 | groupContainer.empty(); 219 | new Setting(groupContainer).setHeading().setName('Color Groups'); 220 | 221 | this.settings.groups.forEach((group, index) => { 222 | new Setting(groupContainer) 223 | .addText(text => text 224 | .setPlaceholder('path:, tag:, file:, or text') 225 | .setValue(group.query) 226 | .onChange(async (value) => { 227 | group.query = value; 228 | await this.plugin.saveSettings(); 229 | this.updateColors(); 230 | })) 231 | .addColorPicker(color => color 232 | .setValue(group.color) 233 | .onChange(async (value) => { 234 | group.color = value; 235 | await this.plugin.saveSettings(); 236 | this.updateColors(); 237 | })) 238 | .addExtraButton(button => button 239 | .setIcon('cross') 240 | .setTooltip('Remove group') 241 | .onClick(async () => { 242 | this.settings.groups.splice(index, 1); 243 | await this.plugin.saveSettings(); 244 | render(); 245 | this.updateColors(); 246 | })); 247 | }); 248 | 249 | new Setting(groupContainer) 250 | .addButton(button => button 251 | .setButtonText('Add new group') 252 | .onClick(async () => { 253 | this.settings.groups.push({ query: '', color: '#ffffff' }); 254 | await this.plugin.saveSettings(); 255 | render(); 256 | })); 257 | }; 258 | render(); 259 | } 260 | 261 | private renderAppearanceSettings(container: HTMLElement) { 262 | new Setting(container).setHeading().setName('Appearance'); 263 | 264 | const updateDisplayAndColors = async () => { 265 | await this.plugin.saveSettings(); 266 | this.updateDisplay(); 267 | } 268 | 269 | new Setting(container).setName('Node size').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.settings.nodeSize).setDynamicTooltip() 270 | .onChange(async (v) => { this.settings.nodeSize = v; await updateDisplayAndColors(); })); 271 | new Setting(container).setName('Tag node size').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.settings.tagNodeSize).setDynamicTooltip() 272 | .onChange(async (v) => { this.settings.tagNodeSize = v; await updateDisplayAndColors(); })); 273 | new Setting(container).setName('Attachment node size').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.settings.attachmentNodeSize).setDynamicTooltip() 274 | .onChange(async (v) => { this.settings.attachmentNodeSize = v; await updateDisplayAndColors(); })); 275 | new Setting(container).setName('Link thickness').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.settings.linkThickness).setDynamicTooltip() 276 | .onChange(async (v) => { this.settings.linkThickness = v; await updateDisplayAndColors(); })); 277 | 278 | new Setting(container).setName('Node shape').addDropdown(dd => dd.addOptions(NodeShape).setValue(this.settings.nodeShape) 279 | .onChange(async(value: NodeShape) => {this.settings.nodeShape = value; await updateDisplayAndColors()})); 280 | new Setting(container).setName('Tag shape').addDropdown(dd => dd.addOptions(NodeShape).setValue(this.settings.tagShape) 281 | .onChange(async(value: NodeShape) => {this.settings.tagShape = value; await updateDisplayAndColors()})); 282 | new Setting(container).setName('Attachment shape').addDropdown(dd => dd.addOptions(NodeShape).setValue(this.settings.attachmentShape) 283 | .onChange(async(value: NodeShape) => {this.settings.attachmentShape = value; await updateDisplayAndColors()})); 284 | } 285 | 286 | private renderLabelSettings(container: HTMLElement) { 287 | new Setting(container).setHeading().setName('Labels'); 288 | 289 | new Setting(container) 290 | .setName('Show node labels') 291 | .setDesc('If you enable this, please reopen the graph view to see the labels.') 292 | .addToggle(toggle => toggle.setValue(this.settings.showNodeLabels) 293 | .onChange(async (value) => { 294 | this.settings.showNodeLabels = value; 295 | await this.plugin.saveSettings(); 296 | 297 | if (!value) { 298 | this.graph.graphData().nodes.forEach((node: GraphNode) => this.cleanupNode(node, { cleanMesh: false, cleanGroup: false })); 299 | } 300 | this.updateDisplay(); 301 | })); 302 | 303 | new Setting(container) 304 | .setName('Label distance') 305 | .addSlider(s => s.setLimits(50, 500, 10).setValue(this.settings.labelDistance).setDynamicTooltip() 306 | .onChange(async (v) => { 307 | this.settings.labelDistance = v; 308 | await this.plugin.saveSettings(); 309 | })); 310 | 311 | new Setting(container) 312 | .setName('Prevent label occlusion') 313 | .setDesc('Can impact performance on large graphs.') 314 | .addToggle(toggle => toggle.setValue(this.settings.labelOcclusion) 315 | .onChange(async (value) => { 316 | this.settings.labelOcclusion = value; 317 | await this.plugin.saveSettings(); 318 | })); 319 | } 320 | 321 | private renderInteractionSettings(container: HTMLElement) { 322 | new Setting(container).setHeading().setName('Interaction'); 323 | 324 | new Setting(container).setName("Use Keyboard Controls (WASD)") 325 | .addToggle(toggle => toggle.setValue(this.settings.useKeyboardControls) 326 | .onChange(async (value) => { this.settings.useKeyboardControls = value; await this.plugin.saveSettings(); this.updateControls() })); 327 | 328 | new Setting(container).setName('Keyboard move speed').addSlider(s => s.setLimits(0.1, 10, 0.1).setValue(this.settings.keyboardMoveSpeed).setDynamicTooltip() 329 | .onChange(async (v) => { this.settings.keyboardMoveSpeed = v; await this.plugin.saveSettings(); })); 330 | 331 | new Setting(container).setName("Zoom on click") 332 | .addToggle(toggle => toggle.setValue(this.settings.zoomOnClick) 333 | .onChange(async (value) => { 334 | this.settings.zoomOnClick = value; 335 | await this.plugin.saveSettings(); 336 | })); 337 | 338 | new Setting(container).setName('Rotation speed').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.settings.rotateSpeed).setDynamicTooltip() 339 | .onChange(async (v) => { 340 | this.settings.rotateSpeed = v; 341 | await this.plugin.saveSettings(); 342 | this.updateControls(); 343 | })); 344 | 345 | new Setting(container).setName('Pan speed').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.settings.panSpeed).setDynamicTooltip() 346 | .onChange(async (v) => { 347 | this.settings.panSpeed = v; 348 | await this.plugin.saveSettings(); 349 | this.updateControls(); 350 | })); 351 | 352 | new Setting(container).setName('Zoom speed').addSlider(s => s.setLimits(0.1, 5, 0.1).setValue(this.settings.zoomSpeed).setDynamicTooltip() 353 | .onChange(async (v) => { 354 | this.settings.zoomSpeed = v; 355 | await this.plugin.saveSettings(); 356 | this.updateControls(); 357 | })); 358 | } 359 | 360 | private renderForceSettings(container: HTMLElement) { 361 | new Setting(container).setHeading().setName('Forces'); 362 | 363 | const forceChangeHandler = async () => { 364 | await this.plugin.saveSettings(); 365 | this.updateData({ useCache: false, reheat: true }); 366 | }; 367 | 368 | new Setting(container) 369 | .setName('Center force') 370 | .addSlider(slider => slider 371 | .setLimits(0, 1, 0.01) 372 | .setValue(this.settings.centerForce) 373 | .setDynamicTooltip() 374 | .onChange(async (value) => { 375 | this.settings.centerForce = value; 376 | await forceChangeHandler(); 377 | })); 378 | 379 | new Setting(container) 380 | .setName('Repel force') 381 | .addSlider(slider => slider 382 | .setLimits(0, 20, 0.1) 383 | .setValue(this.settings.repelForce) 384 | .setDynamicTooltip() 385 | .onChange(async (value) => { 386 | this.settings.repelForce = value; 387 | await forceChangeHandler(); 388 | })); 389 | 390 | new Setting(container) 391 | .setName('Link force') 392 | .addSlider(slider => slider 393 | .setLimits(0, 0.1, 0.001) 394 | .setValue(this.settings.linkForce) 395 | .setDynamicTooltip() 396 | .onChange(async (value) => { 397 | this.settings.linkForce = value; 398 | await forceChangeHandler(); 399 | })); 400 | } 401 | 402 | private initializeForces() { 403 | this.chargeForce = this.graph.d3Force('charge'); 404 | this.centerForce = this.graph.d3Force('center'); 405 | this.linkForce = this.graph.d3Force('link'); 406 | } 407 | 408 | private handleKeyDown(event: KeyboardEvent) { 409 | const movementKeys = ['w', 'a', 's', 'd', 'q', 'e']; 410 | const key = event.key.toLowerCase(); 411 | 412 | if (this.settings.useKeyboardControls && movementKeys.includes(key)) { 413 | event.preventDefault(); 414 | this.pressedKeys.add(key); 415 | } 416 | } 417 | 418 | private handleKeyUp(event: KeyboardEvent) { 419 | if (this.settings.useKeyboardControls) { 420 | this.pressedKeys.delete(event.key.toLowerCase()); 421 | } 422 | } 423 | 424 | private handleKeyboardMovement() { 425 | if (!this.settings.useKeyboardControls || this.pressedKeys.size === 0) return; 426 | 427 | const controls = this.graph.controls(); 428 | const camera = this.graph.camera(); 429 | if (!controls || !camera) return; 430 | 431 | const moveSpeed = this.settings.keyboardMoveSpeed; 432 | const direction = new THREE.Vector3(); 433 | camera.getWorldDirection(direction); 434 | 435 | const right = new THREE.Vector3(); 436 | right.crossVectors(direction, camera.up).normalize(); 437 | 438 | const moveVector = new THREE.Vector3(); 439 | 440 | if (this.pressedKeys.has('w')) moveVector.add(direction); 441 | if (this.pressedKeys.has('s')) moveVector.sub(direction); 442 | if (this.pressedKeys.has('a')) moveVector.sub(right); 443 | if (this.pressedKeys.has('d')) moveVector.add(right); 444 | 445 | if (moveVector.lengthSq() > 0) { 446 | moveVector.normalize().multiplyScalar(moveSpeed); 447 | const newPos = new THREE.Vector3().copy(camera.position).add(moveVector); 448 | const newTarget = new THREE.Vector3().copy(controls.target).add(moveVector); 449 | this.graph.cameraPosition(newPos, newTarget); 450 | } 451 | 452 | if (this.pressedKeys.has('e')) { 453 | const newPos = new THREE.Vector3().copy(camera.position); 454 | newPos.y += moveSpeed; 455 | const newTarget = new THREE.Vector3().copy(controls.target); 456 | newTarget.y += moveSpeed; 457 | this.graph.cameraPosition(newPos, newTarget); 458 | } 459 | if (this.pressedKeys.has('q')) { 460 | const newPos = new THREE.Vector3().copy(camera.position); 461 | newPos.y -= moveSpeed; 462 | const newTarget = new THREE.Vector3().copy(controls.target); 463 | newTarget.y -= moveSpeed; 464 | this.graph.cameraPosition(newPos, newTarget); 465 | } 466 | } 467 | 468 | initializeGraph() { 469 | this.app.workspace.onLayoutReady(async () => { 470 | if (!this.graphContainer) return; 471 | 472 | const Graph = (ForceGraph3D as any).default || ForceGraph3D; 473 | this.graph = Graph()(this.graphContainer) 474 | .onNodeClick((node: GraphNode, event: MouseEvent) => this.handleNodeClick(node, event)) 475 | .onNodeHover((node: GraphNode | null) => this.handleNodeHover(node)) 476 | .linkCurvature((link: ProcessedGraphLink) => this.getLinkCurvature(link)) 477 | .onEngineTick(() => { 478 | const now = performance.now(); 479 | if (now - this.lastLabelUpdateTime > this.LABEL_UPDATE_INTERVAL) { 480 | this.lastLabelUpdateTime = now; 481 | this.updateLabels(); 482 | } 483 | this.handleKeyboardMovement(); 484 | }); 485 | 486 | this.graph.graphData({ nodes: [], links: [] }); 487 | 488 | this.initializeForces(); 489 | this.graph.pauseAnimation(); 490 | this.isGraphInitialized = true; 491 | 492 | setTimeout(() => { this.updateData({ isFirstLoad: true }); }, 100); 493 | }); 494 | } 495 | 496 | public async updateData(options: { useCache?: boolean; reheat?: boolean; isFirstLoad?: boolean } = {}) { 497 | const { useCache = true, reheat = false, isFirstLoad = false } = options; 498 | 499 | if (!this.isGraphInitialized || this.isUpdating) { 500 | return; 501 | } 502 | this.isUpdating = true; 503 | 504 | try { 505 | const nodePositions = new Map(); 506 | if (useCache && this.graph.graphData().nodes.length > 0) { 507 | this.graph.graphData().nodes.forEach((node: GraphNode) => { 508 | if (node.id && node.x !== undefined && node.y !== undefined && node.z !== undefined) { 509 | nodePositions.set(node.id, { x: node.x, y: node.y, z: node.z }); 510 | } 511 | }); 512 | } 513 | 514 | const newData = await this.processVaultData(); 515 | const hasNodes = newData && newData.nodes.length > 0; 516 | 517 | const oldNodes = this.graph.graphData().nodes as GraphNode[]; 518 | if (oldNodes.length > 0) { 519 | const newNodeIds = new Set(hasNodes ? newData.nodes.map(n => n.id) : []); 520 | const nodesToRemove = oldNodes.filter(node => !newNodeIds.has(node.id)); 521 | nodesToRemove.forEach(node => this.cleanupNode(node)); 522 | } 523 | 524 | if (hasNodes) { 525 | if (useCache) { 526 | const adjacencyMap: Map = new Map(); 527 | newData.links.forEach(link => { 528 | const sourceId = link.source as string; 529 | const targetId = link.target as string; 530 | 531 | if (!adjacencyMap.has(sourceId)) adjacencyMap.set(sourceId, []); 532 | if (!adjacencyMap.has(targetId)) adjacencyMap.set(targetId, []); 533 | 534 | adjacencyMap.get(sourceId)!.push(targetId); 535 | adjacencyMap.get(targetId)!.push(sourceId); 536 | }); 537 | 538 | newData.nodes.forEach(node => { 539 | const cachedPos = nodePositions.get(node.id); 540 | if (cachedPos) { 541 | node.x = cachedPos.x; 542 | node.y = cachedPos.y; 543 | node.z = cachedPos.z; 544 | } else { 545 | const neighbors = adjacencyMap.get(node.id) || []; 546 | let connectedNodePos: {x:number, y:number, z:number} | undefined; 547 | 548 | for (const neighborId of neighbors) { 549 | connectedNodePos = nodePositions.get(neighborId); 550 | if (connectedNodePos) break; 551 | } 552 | 553 | if (connectedNodePos) { 554 | node.x = connectedNodePos.x + (Math.random() - 0.5) * 2; 555 | node.y = connectedNodePos.y + (Math.random() - 0.5) * 2; 556 | node.z = connectedNodePos.z + (Math.random() - 0.5) * 2; 557 | } 558 | } 559 | }); 560 | } 561 | 562 | this.graph.pauseAnimation(); 563 | this.messageEl.removeClass('is-visible'); 564 | this.graph.graphData(newData); 565 | this.occludersCacheDirty = true; 566 | 567 | this.updateForces(); 568 | this.updateDisplay(); 569 | this.updateColors(); 570 | this.updateControls(); 571 | 572 | if (isFirstLoad || reheat) { 573 | this.graph.d3AlphaDecay(0.0228); 574 | this.graph.d3VelocityDecay(0.4); 575 | this.graph.d3ReheatSimulation(); 576 | } else if (useCache) { 577 | this.graph.d3AlphaDecay(0.1); 578 | this.graph.d3VelocityDecay(0.6); 579 | } 580 | 581 | this.graph.resumeAnimation(); 582 | } else { 583 | if (this.graph && typeof this.graph._destructor === 'function') { 584 | this.graph._destructor(); 585 | } 586 | const Graph = (ForceGraph3D as any).default || ForceGraph3D; 587 | this.graph = Graph()(this.graphContainer) 588 | .onNodeClick((node: GraphNode) => this.handleNodeClick(node)) 589 | .graphData({ nodes: [], links: [] }); 590 | 591 | this.initializeForces(); 592 | 593 | this.colorCache.clear(); 594 | const bgColor = this.settings.useThemeColors 595 | ? this.getCssColor('--background-primary', '#000000') 596 | : this.settings.backgroundColor; 597 | this.graph.backgroundColor(bgColor); 598 | this.messageEl.setText("No search results found."); 599 | this.messageEl.addClass('is-visible'); 600 | this.graph.pauseAnimation(); 601 | } 602 | } catch (error) { 603 | console.error('3D Graph: An error occurred during updateData:', error); 604 | } finally { 605 | this.isUpdating = false; 606 | } 607 | } 608 | 609 | public updateColors() { 610 | if (!this.isGraphInitialized) return; 611 | 612 | this.colorCache.clear(); 613 | 614 | const bgColor = this.settings.useThemeColors ? this.getCssColor('--background-primary', '#000000') : this.settings.backgroundColor; 615 | this.graph.backgroundColor(bgColor); 616 | 617 | this.graph.graphData().nodes.forEach((node: GraphNode) => { 618 | const mesh = this.nodeMeshes.get(node); 619 | if (mesh && mesh.material) { 620 | const color = this.getNodeColor(node); 621 | if (color) { 622 | try { 623 | (mesh.material as THREE.MeshLambertMaterial).color.set(color); 624 | } catch (e) { 625 | console.error(`3D Graph: Invalid color '${color}' for node`, node, e); 626 | } 627 | } 628 | } 629 | }); 630 | 631 | const linkHighlightColor = this.settings.useThemeColors ? this.getCssColor('--graph-node-focused', this.settings.colorHighlight) : this.settings.colorHighlight; 632 | const linkColor = this.settings.useThemeColors ? this.getCssColor('--graph-line', this.settings.colorLink) : this.settings.colorLink; 633 | this.graph.linkColor((link: GraphLink) => this.highlightedLinks.has(link) ? linkHighlightColor : linkColor); 634 | } 635 | 636 | private getCssColor(variable: string, fallback: string): string { 637 | if (this.colorCache.has(variable)) { 638 | return this.colorCache.get(variable)!; 639 | } 640 | 641 | try { 642 | const tempEl = document.createElement('div'); 643 | tempEl.style.display = 'none'; 644 | tempEl.style.color = `var(${variable})`; 645 | document.body.appendChild(tempEl); 646 | 647 | const computedColor = getComputedStyle(tempEl).color; 648 | document.body.removeChild(tempEl); 649 | 650 | const canvas = document.createElement('canvas'); 651 | canvas.width = 1; 652 | canvas.height = 1; 653 | const ctx = canvas.getContext('2d'); 654 | if (!ctx) { 655 | this.colorCache.set(variable, fallback); 656 | return fallback; 657 | } 658 | ctx.fillStyle = computedColor; 659 | ctx.fillRect(0, 0, 1, 1); 660 | const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; 661 | 662 | const finalColor = `rgb(${r}, ${g}, ${b})`; 663 | this.colorCache.set(variable, finalColor); 664 | return finalColor; 665 | 666 | } catch (e) { 667 | console.error(`3D Graph: Could not parse CSS color variable ${variable}`, e); 668 | this.colorCache.set(variable, fallback); 669 | return fallback; 670 | } 671 | } 672 | 673 | private getNodeColor(node: GraphNode): string { 674 | const { useThemeColors, colorHighlight, colorNode, colorTag, colorAttachment, groups } = this.settings; 675 | 676 | if (this.highlightedNodes.has(node.id)) { 677 | return useThemeColors ? this.getCssColor('--graph-node-focused', colorHighlight) : colorHighlight; 678 | } 679 | 680 | for (const group of groups) { 681 | const query = group.query.toLowerCase(); 682 | if (!query) continue; 683 | 684 | if (query.startsWith('path:')) { 685 | const pathQuery = query.substring(5).trim(); 686 | if (node.type !== NodeType.Tag && node.id.toLowerCase().startsWith(pathQuery)) { 687 | return group.color; 688 | } 689 | } else if (query.startsWith('tag:')) { 690 | const tagQuery = query.substring(4).trim().replace(/^#/, ''); 691 | if (node.type === NodeType.Tag && node.name.toLowerCase() === `#${tagQuery}`) { 692 | return group.color; 693 | } 694 | if (node.type === NodeType.File && node.tags?.some(tag => tag.toLowerCase() === tagQuery)) { 695 | return group.color; 696 | } 697 | } else if (query.startsWith('file:')) { 698 | const fileQuery = query.substring(5).trim().toLowerCase(); 699 | if ((node.type === NodeType.File || node.type === NodeType.Attachment) && node.filename) { 700 | if (fileQuery.includes('*')) { 701 | const pattern = fileQuery.replace(/\./g, '\\.').replace(/\*/g, '.*'); 702 | const regex = new RegExp(`^${pattern}$`, 'i'); 703 | if (regex.test(node.filename)) { 704 | return group.color; 705 | } 706 | } else { 707 | if (node.filename.toLowerCase() === fileQuery) { 708 | return group.color; 709 | } 710 | } 711 | } 712 | } else { 713 | if (node.name.toLowerCase().includes(query) || (node.content && node.content.toLowerCase().includes(query))) { 714 | return group.color; 715 | } 716 | } 717 | } 718 | 719 | if (useThemeColors) { 720 | if (node.type === NodeType.Tag) return this.getCssColor('--graph-tags', colorTag); 721 | if (node.type === NodeType.Attachment) return this.getCssColor('--graph-unresolved', colorAttachment); 722 | return this.getCssColor('--graph-node', colorNode); 723 | } else { 724 | if (node.type === NodeType.Tag) return colorTag; 725 | if (node.type === NodeType.Attachment) return colorAttachment; 726 | return colorNode; 727 | } 728 | } 729 | 730 | public updateDisplay() { 731 | if (!this.isGraphInitialized) return; 732 | // This function is now only for things that require a full object recreation 733 | this.graph 734 | .nodeThreeObject((node: GraphNode) => this.createNodeObject(node)); 735 | 736 | // These are now updated dynamically without a full redraw 737 | this.graph 738 | .linkWidth((link: GraphLink) => this.highlightedLinks.has(link) ? (this.settings.linkThickness * 2) : this.settings.linkThickness) 739 | .linkDirectionalParticles((link: GraphLink) => this.highlightedLinks.has(link) ? 4 : 0) 740 | .linkDirectionalParticleWidth(2); 741 | } 742 | 743 | private hexToRgba(hex: string, alpha: number): string { 744 | const r = parseInt(hex.slice(1, 3), 16); 745 | const g = parseInt(hex.slice(3, 5), 16); 746 | const b = parseInt(hex.slice(5, 7), 16); 747 | return `rgba(${r}, ${g}, ${b}, ${alpha})`; 748 | } 749 | 750 | private createNodeObject(node: GraphNode): THREE.Object3D { 751 | const group = new THREE.Group(); 752 | 753 | let shape: NodeShape; 754 | let size: number; 755 | switch (node.type) { 756 | case NodeType.Tag: shape = this.settings.tagShape; size = this.settings.tagNodeSize; break; 757 | case NodeType.Attachment: shape = this.settings.attachmentShape; size = this.settings.attachmentNodeSize; break; 758 | default: shape = this.settings.nodeShape; size = this.settings.nodeSize; 759 | } 760 | 761 | let geometry: THREE.BufferGeometry; 762 | const s = size * 1.5; 763 | 764 | switch (shape) { 765 | case NodeShape.Cube: geometry = new THREE.BoxGeometry(s, s, s); break; 766 | case NodeShape.Pyramid: geometry = new THREE.ConeGeometry(s / 1.5, s, 4); break; 767 | case NodeShape.Tetrahedron: geometry = new THREE.TetrahedronGeometry(s / 1.2); break; 768 | default: geometry = new THREE.SphereGeometry(s / 2); 769 | } 770 | 771 | const color = this.getNodeColor(node); 772 | const material = new THREE.MeshLambertMaterial({ 773 | color: '#ffffff', 774 | transparent: true, 775 | opacity: 0.9 776 | }); 777 | 778 | try { 779 | material.color.set(color); 780 | } catch (e) { 781 | console.error(`3D Graph: Could not set material color to '${color}'`, e); 782 | material.color.set(this.settings.colorNode); 783 | } 784 | 785 | const mesh = new THREE.Mesh(geometry, material); 786 | this.nodeMeshes.set(node, mesh); 787 | group.add(mesh); 788 | 789 | if (this.settings.showNodeLabels) { 790 | const sprite = new SpriteText(node.name); 791 | const isDarkMode = document.body.classList.contains('theme-dark'); 792 | sprite.color = isDarkMode ? this.settings.labelTextColorDark : this.settings.labelTextColorLight; 793 | sprite.backgroundColor = this.hexToRgba(this.settings.labelBackgroundColor, this.settings.labelBackgroundOpacity); 794 | sprite.textHeight = this.settings.labelTextSize; 795 | sprite.position.y = s / 2 + 2; 796 | this.nodeSprites.set(node, sprite); 797 | group.add(sprite); 798 | } 799 | 800 | return group; 801 | } 802 | 803 | public updateForces() { 804 | if (!this.isGraphInitialized) return; 805 | 806 | const { centerForce, repelForce, linkForce } = this.settings; 807 | 808 | if (this.centerForce) { 809 | this.centerForce.strength(centerForce); 810 | } 811 | if (this.chargeForce) { 812 | this.chargeForce.strength(-repelForce); 813 | } 814 | if (this.linkForce) { 815 | this.linkForce.strength(linkForce); 816 | } 817 | } 818 | 819 | public updateControls() { 820 | if (!this.isGraphInitialized) return; 821 | const { rotateSpeed, panSpeed, zoomSpeed } = this.settings; 822 | const controls = this.graph.controls(); 823 | if (controls) { 824 | controls.rotateSpeed = rotateSpeed; 825 | controls.panSpeed = panSpeed; 826 | controls.zoomSpeed = zoomSpeed; 827 | } 828 | } 829 | 830 | private updateLabels() { 831 | if (!this.isGraphInitialized || !this.settings.showNodeLabels) return; 832 | 833 | const camera = this.graph.camera(); 834 | const nodes = this.graph.graphData().nodes; 835 | 836 | if (!nodes || !camera) return; 837 | 838 | if (this.settings.labelOcclusion && this.occludersCacheDirty) { 839 | this.cachedOccluders = nodes.map((n: GraphNode) => this.nodeMeshes.get(n)).filter(Boolean) as THREE.Mesh[]; 840 | this.occludersCacheDirty = false; 841 | } 842 | 843 | const relevantOccluders = this.settings.labelOcclusion 844 | ? this.cachedOccluders.filter(mesh => camera.position.distanceTo(mesh.position) < this.RAYCAST_CULL_DISTANCE) 845 | : []; 846 | 847 | nodes.forEach((node: GraphNode) => { 848 | const sprite = this.nodeSprites.get(node); 849 | if (!sprite) return; 850 | 851 | if (node.__threeObj) { 852 | node.__threeObj.getWorldPosition(this.reusableNodePosition); 853 | } else { 854 | sprite.visible = false; 855 | return; 856 | } 857 | 858 | const distance = camera.position.distanceTo(this.reusableNodePosition); 859 | 860 | const visibleDistance = this.settings.labelDistance; 861 | const fadeStartDistance = visibleDistance * this.settings.labelFadeThreshold; 862 | 863 | let opacity = 0; 864 | 865 | if (distance <= fadeStartDistance) { 866 | opacity = 1; 867 | } else if (distance <= visibleDistance) { 868 | opacity = 1 - (distance - fadeStartDistance) / (visibleDistance - fadeStartDistance); 869 | } 870 | 871 | if (opacity > 0 && this.settings.labelOcclusion && relevantOccluders.length > 1) { 872 | const direction = this.reusableDirection.subVectors(this.reusableNodePosition, camera.position).normalize(); 873 | this.raycaster.set(camera.position, direction); 874 | const intersects = this.raycaster.intersectObjects(relevantOccluders); 875 | const mesh = this.nodeMeshes.get(node); 876 | 877 | if (intersects.length > 0 && intersects[0].object !== mesh) { 878 | if (intersects[0].distance < distance) { 879 | opacity = 0; 880 | } 881 | } 882 | } 883 | 884 | (sprite.material as THREE.SpriteMaterial).opacity = opacity; 885 | sprite.visible = opacity > 0.01; 886 | }); 887 | } 888 | 889 | private handleNodeClick(node: GraphNode, event?: MouseEvent) { 890 | if (!node) return; 891 | 892 | if (event && (event.ctrlKey || event.metaKey)) { 893 | this.app.workspace.openLinkText(node.id, node.id, 'tab'); 894 | return; 895 | } 896 | 897 | if (this.clickTimeout) { 898 | clearTimeout(this.clickTimeout); this.clickTimeout = null; 899 | this.handleNodeDoubleClick(node); 900 | } else { 901 | this.clickTimeout = setTimeout(() => { 902 | this.handleNodeSingleClick(node); this.clickTimeout = null; 903 | }, this.CLICK_DELAY); 904 | } 905 | } 906 | 907 | private handleNodeDoubleClick(node: GraphNode) { 908 | if (node.type === NodeType.File || node.type === NodeType.Attachment) { 909 | const file = this.app.vault.getAbstractFileByPath(node.id); 910 | if (file instanceof TFile) this.app.workspace.getLeaf('tab').openFile(file); 911 | } 912 | } 913 | 914 | private handleNodeSingleClick(node: GraphNode) { 915 | if (this.selectedNode === node.id) { 916 | this.selectedNode = null; 917 | this.highlightedNodes.clear(); 918 | this.highlightedLinks.clear(); 919 | } else { 920 | this.selectedNode = node.id; 921 | this.highlightedNodes.clear(); 922 | this.highlightedLinks.clear(); 923 | this.highlightedNodes.add(node.id); 924 | 925 | const allLinks = this.graph.graphData().links; 926 | 927 | allLinks.forEach((link: ProcessedGraphLink) => { 928 | if (link.source.id === node.id) { 929 | this.highlightedNodes.add(link.target.id); 930 | this.highlightedLinks.add(link); 931 | } else if (link.target.id === node.id) { 932 | this.highlightedNodes.add(link.source.id); 933 | this.highlightedLinks.add(link); 934 | } 935 | }); 936 | 937 | if (node.__threeObj && this.settings.zoomOnClick) { 938 | const distance = 40; 939 | const nodePosition = new THREE.Vector3(); 940 | node.__threeObj.getWorldPosition(nodePosition); 941 | const cameraPosition = this.graph.camera().position; 942 | const direction = new THREE.Vector3().subVectors(cameraPosition, nodePosition).normalize(); 943 | const targetPosition = new THREE.Vector3().addVectors(nodePosition, direction.multiplyScalar(distance)); 944 | this.graph.cameraPosition(targetPosition, nodePosition, 1000); 945 | } 946 | } 947 | this.updateColors(); 948 | this.graph.linkWidth(this.graph.linkWidth()); 949 | this.graph.linkDirectionalParticles(this.graph.linkDirectionalParticles()); 950 | } 951 | 952 | private handleNodeHover(node: GraphNode | null) { 953 | if (this.hoveredNode && this.hoveredNode !== node) { 954 | this.hoveredNode.fx = undefined; 955 | this.hoveredNode.fy = undefined; 956 | this.hoveredNode.fz = undefined; 957 | } 958 | 959 | this.hoveredNode = node; 960 | if (this.hoveredNode) { 961 | this.hoveredNode.fx = this.hoveredNode.x; 962 | this.hoveredNode.fy = this.hoveredNode.y; 963 | this.hoveredNode.fz = this.hoveredNode.z; 964 | } 965 | 966 | this.highlightedNodes.clear(); 967 | this.highlightedLinks.clear(); 968 | 969 | if (node) { 970 | this.highlightedNodes.add(node.id); 971 | this.graph.graphData().links.forEach((link: ProcessedGraphLink) => { 972 | if (link.source.id === node.id || link.target.id === node.id) { 973 | this.highlightedLinks.add(link); 974 | } 975 | }); 976 | } 977 | this.updateColors(); 978 | this.graph.linkWidth(this.graph.linkWidth()); 979 | this.graph.linkDirectionalParticles(this.graph.linkDirectionalParticles()); 980 | } 981 | 982 | private getLinkCurvature(link: ProcessedGraphLink) { 983 | const allLinks = this.graph.graphData().links; 984 | const hasReciprocal = allLinks.some((l: ProcessedGraphLink) => l.source.id === link.target.id && l.target.id === link.source.id); 985 | if (hasReciprocal) { 986 | return link.source.id > link.target.id ? 0.2 : -0.2; 987 | } 988 | return 0; 989 | } 990 | 991 | private matchesFilter(node: GraphNode, filter: Filter): boolean { 992 | const filterValue = filter.value.trim().toLowerCase(); 993 | if (!filterValue) return false; 994 | 995 | if (filter.type === 'path') { 996 | return node.id.toLowerCase().includes(filterValue); 997 | } 998 | if (filter.type === 'tag') { 999 | const tagToMatch = filterValue.startsWith('#') ? filterValue.substring(1) : filterValue; 1000 | return node.tags?.some(tag => tag.toLowerCase() === tagToMatch) ?? false; 1001 | } 1002 | return false; 1003 | } 1004 | 1005 | private async processVaultData(): Promise<{ nodes: GraphNode[], links: { source: string, target: string }[] } | null> { 1006 | const { showAttachments, hideOrphans, showTags, searchQuery, showNeighboringNodes, filters } = this.settings; 1007 | const allFiles = this.app.vault.getFiles(); 1008 | const resolvedLinks = this.app.metadataCache.resolvedLinks; 1009 | if (!resolvedLinks) return null; 1010 | 1011 | const allNodesMap = new Map(); 1012 | 1013 | for (const file of allFiles) { 1014 | const cache = this.app.metadataCache.getFileCache(file); 1015 | const tags = cache?.tags?.map(t => t.tag.substring(1)) || []; 1016 | const type = file.extension === 'md' ? NodeType.File : NodeType.Attachment; 1017 | 1018 | let content = ''; 1019 | if (type === NodeType.File) { 1020 | content = await this.app.vault.cachedRead(file); 1021 | } 1022 | 1023 | allNodesMap.set(file.path, { id: file.path, name: file.basename, filename: file.name, type, tags, content }); 1024 | } 1025 | 1026 | 1027 | const allLinks: { source: string, target: string }[] = []; 1028 | for (const sourcePath in resolvedLinks) { 1029 | for (const targetPath in resolvedLinks[sourcePath]) { 1030 | allLinks.push({ source: sourcePath, target: targetPath }); 1031 | } 1032 | } 1033 | 1034 | if (showTags) { 1035 | const allTags = new Map(); 1036 | allNodesMap.forEach(node => { 1037 | if (node.type === NodeType.File && node.tags) { 1038 | node.tags.forEach(tagName => { 1039 | const tagId = `tag:${tagName}`; 1040 | if (!allTags.has(tagName)) { 1041 | allTags.set(tagName, { id: tagId, name: `#${tagName}`, type: NodeType.Tag }); 1042 | } 1043 | allLinks.push({ source: node.id, target: tagId }); 1044 | }); 1045 | } 1046 | }); 1047 | allTags.forEach((tagNode, tagName) => allNodesMap.set(tagNode.id, tagNode)); 1048 | } 1049 | 1050 | let finalNodes = Array.from(allNodesMap.values()); 1051 | 1052 | // Advanced Filtering Logic 1053 | const positiveFilters = filters.filter(f => !f.inverted && f.value.trim() !== ''); 1054 | const negativeFilters = filters.filter(f => f.inverted && f.value.trim() !== ''); 1055 | 1056 | if (positiveFilters.length > 0) { 1057 | const nodesToKeep = new Set(); 1058 | positiveFilters.forEach(filter => { 1059 | finalNodes.forEach(node => { 1060 | if (this.matchesFilter(node, filter)) { 1061 | nodesToKeep.add(node); 1062 | } 1063 | }); 1064 | }); 1065 | finalNodes = Array.from(nodesToKeep); 1066 | } 1067 | 1068 | if (negativeFilters.length > 0) { 1069 | finalNodes = finalNodes.filter(node => { 1070 | return !negativeFilters.some(filter => this.matchesFilter(node, filter)); 1071 | }); 1072 | } 1073 | 1074 | 1075 | if (searchQuery) { 1076 | const lowerCaseFilter = searchQuery.toLowerCase(); 1077 | finalNodes = finalNodes.filter(node => { 1078 | const nodeContent = node.content || ''; 1079 | return node.name.toLowerCase().includes(lowerCaseFilter) || 1080 | (node.type !== NodeType.Tag && node.id.toLowerCase().includes(lowerCaseFilter)) || 1081 | nodeContent.toLowerCase().includes(lowerCaseFilter); 1082 | }); 1083 | } 1084 | 1085 | const finalNodeIds = new Set(finalNodes.map(n => n.id)); 1086 | let finalLinks = allLinks.filter(link => finalNodeIds.has(link.source) && finalNodeIds.has(link.target)); 1087 | 1088 | let nodesToShow = finalNodes.filter(node => { 1089 | if (node.type === NodeType.Tag) return showTags; 1090 | if (node.type === NodeType.Attachment) return showAttachments; 1091 | return true; 1092 | }); 1093 | 1094 | let nodesToShowIds = new Set(nodesToShow.map(n => n.id)); 1095 | let linksToShow = finalLinks.filter(link => nodesToShowIds.has(link.source) && nodesToShowIds.has(link.target)); 1096 | 1097 | if (hideOrphans) { 1098 | const linkedNodeIds = new Set(); 1099 | linksToShow.forEach(link => { 1100 | linkedNodeIds.add(link.source); 1101 | linkedNodeIds.add(link.target); 1102 | }); 1103 | nodesToShow = nodesToShow.filter(node => linkedNodeIds.has(node.id)); 1104 | 1105 | const visibleNodeIds = new Set(nodesToShow.map(n => n.id)); 1106 | linksToShow = linksToShow.filter(l => visibleNodeIds.has(l.source) && visibleNodeIds.has(l.target)); 1107 | } 1108 | 1109 | return { nodes: nodesToShow, links: linksToShow }; 1110 | } 1111 | 1112 | private cleanupNode(node: GraphNode, options: { cleanMesh?: boolean, cleanGroup?: boolean } = { cleanMesh: true, cleanGroup: true }) { 1113 | if (options.cleanMesh) { 1114 | const mesh = this.nodeMeshes.get(node); 1115 | if (mesh) { 1116 | mesh.geometry?.dispose(); 1117 | (mesh.material as THREE.Material)?.dispose(); 1118 | this.nodeMeshes.delete(node); 1119 | } 1120 | } 1121 | 1122 | const sprite = this.nodeSprites.get(node); 1123 | if (sprite) { 1124 | sprite.parent?.remove(sprite); 1125 | sprite.geometry?.dispose(); 1126 | sprite.material?.dispose(); 1127 | this.nodeSprites.delete(node); 1128 | } 1129 | 1130 | if (options.cleanGroup && node.__threeObj) { 1131 | node.__threeObj.parent?.remove(node.__threeObj); 1132 | } 1133 | } 1134 | 1135 | async onClose() { 1136 | if (this.clickTimeout) clearTimeout(this.clickTimeout); 1137 | this.resizeObserver?.disconnect(); 1138 | if (this.graph) { 1139 | this.graph.graphData().nodes.forEach((node: GraphNode) => this.cleanupNode(node)); 1140 | this.isGraphInitialized = false; 1141 | this.graph.pauseAnimation(); 1142 | const renderer = this.graph.renderer(); 1143 | if (renderer?.domElement) { 1144 | renderer.forceContextLoss(); 1145 | renderer.dispose(); 1146 | } 1147 | if (typeof this.graph._destructor === 'function') { 1148 | this.graph._destructor(); 1149 | } 1150 | this.graph = null; 1151 | } 1152 | if (this.messageEl) { 1153 | this.messageEl.remove(); 1154 | } 1155 | } 1156 | } 1157 | --------------------------------------------------------------------------------