├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── copy_to_vault.sh ├── docs ├── dev-docs.md └── res │ └── showcase.gif ├── esbuild.config.mjs ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── graph │ ├── Graph.ts │ ├── Link.ts │ └── Node.ts ├── main.ts ├── settings │ ├── GraphSettings.ts │ └── categories │ │ ├── DisplaySettings.ts │ │ ├── FilterSettings.ts │ │ └── GroupSettings.ts ├── util │ ├── EventBus.ts │ ├── ObsidianTheme.ts │ ├── ShallowCompare.ts │ └── State.ts └── views │ ├── atomics │ ├── ColorPicker.ts │ ├── SimpleSliderSetting.ts │ └── TreeItem.ts │ ├── graph │ ├── ForceGraph.ts │ └── Graph3dView.ts │ └── settings │ ├── GraphSettingsView.ts │ └── categories │ ├── DisplaySettingsView.ts │ ├── FilterSettingsView.ts │ └── GroupSettingsView.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.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 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.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/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '🐛 Bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '🚀 Feature' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release plugin 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - "*" # Push events to matching any tag format, i.e. 1.0, 20.15.10 8 | 9 | env: 10 | PLUGIN_NAME: obisidian-3d-graph 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: jetli/wasm-pack-action@v0.3.0 18 | with: 19 | # Optional version of wasm-pack to install(eg. 'v0.9.1', 'latest') 20 | version: "latest" 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: "14.x" # You might need to adjust this value to your own version 26 | - name: Build 27 | id: build 28 | run: | 29 | npm install 30 | npm run build 31 | mkdir ${{ env.PLUGIN_NAME }} 32 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 33 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 34 | ls 35 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 36 | - name: Create Release 37 | id: create_release 38 | uses: actions/create-release@v1 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.OBSIDIAN_3D_GRAPH_TOKEN }} 41 | VERSION: ${{ github.ref }} 42 | with: 43 | tag_name: ${{ github.ref }} 44 | release_name: ${{ github.ref }} 45 | draft: false 46 | prerelease: false 47 | - name: Upload zip file 48 | id: upload-zip 49 | uses: actions/upload-release-asset@v1 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.OBSIDIAN_3D_GRAPH_TOKEN }} 52 | with: 53 | upload_url: ${{ steps.create_release.outputs.upload_url }} 54 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 55 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 56 | asset_content_type: application/zip 57 | - name: Upload main.js 58 | id: upload-main 59 | uses: actions/upload-release-asset@v1 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.OBSIDIAN_3D_GRAPH_TOKEN }} 62 | with: 63 | upload_url: ${{ steps.create_release.outputs.upload_url }} 64 | asset_path: ./main.js 65 | asset_name: main.js 66 | asset_content_type: text/javascript 67 | - name: Upload manifest.json 68 | id: upload-manifest 69 | uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.OBSIDIAN_3D_GRAPH_TOKEN }} 72 | with: 73 | upload_url: ${{ steps.create_release.outputs.upload_url }} 74 | asset_path: ./manifest.json 75 | asset_name: manifest.json 76 | asset_content_type: application/json 77 | - name: Upload main.js 78 | id: upload-css 79 | uses: actions/upload-release-asset@v1 80 | env: 81 | GITHUB_TOKEN: ${{ secrets.OBSIDIAN_3D_GRAPH_TOKEN }} 82 | with: 83 | upload_url: ${{ steps.create_release.outputs.upload_url }} 84 | asset_path: ./styles.css 85 | asset_name: styles.css 86 | asset_content_type: text/css 87 | -------------------------------------------------------------------------------- /.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 | 24 | .obsidian -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alexander Weichart 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Obsidian 3D Graph 2 | 3 | A 3D Graph for Obsidian! 4 | 5 | ### Showcase: 6 | 7 | https://user-images.githubusercontent.com/55558407/190087315-8386feee-b861-4520-bb94-19051c7a46c4.mp4 8 | 9 | ### ⬇️ Installation 10 | 11 | 3D-Graph is an official community plugin. You can download by: 12 | - clicking [here](https://obsidian.md/plugins?id=3d-graph) 13 | - searching for "3D Graph" in the Obsidian plugins tab 14 | 15 | ### 👨‍💻 Development 16 | 17 | The plugin is written in TypeScript and uses D3.js for the graph rendering. 18 | For more information please, check the [dev docs](docs/dev-docs.md). 19 | -------------------------------------------------------------------------------- /copy_to_vault.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # get first argument 4 | vault=$1 5 | 6 | # check if path exists 7 | if [ ! -d "$vault" ]; then 8 | echo "Vault does not exist, did you change the path in package.json?" 9 | exit 1 10 | fi 11 | 12 | plugin_path="$1/.obsidian/plugins/obsidian-note-linker" 13 | 14 | # create plugin directory if it does not exist 15 | 16 | if [ ! -d "$plugin_path" ]; then 17 | echo "Creating plugin directory in $vault" 18 | mkdir -p "$plugin_path" 19 | fi 20 | 21 | # remove all files inside of the directory 22 | echo "Removing old plugin files in $vault" 23 | rm -rf "${plugin_path:?}"/* 24 | 25 | 26 | # copy ./manifest.json, ./styles.css and ./main.js to ~/Desktop/YouTube/.obsidian/plugins/obsidian-note-linker/ 27 | 28 | echo "Copying new plugin files to $vault" 29 | cp ./manifest.json "$plugin_path" 30 | cp ./styles.css "$plugin_path" 31 | cp ./main.js "$plugin_path" 32 | touch "$plugin_path"/.hotreload 33 | 34 | echo "Done" -------------------------------------------------------------------------------- /docs/dev-docs.md: -------------------------------------------------------------------------------- 1 | ## Dev Docs 2 | 3 | ### 📁 Project structure 4 | 5 | - `src/` - source code 6 | - `src/graph/` - Graph data structures and algorithms 7 | - `src/settings/` - Settings data structures 8 | - `src/utils/` - Utility functions 9 | - `src/views/` - Views and UI components 10 | - `./atomics/` - Atomic UI components 11 | - `./graph/` - Graph UI components 12 | - `./settings/` - Settings UI components 13 | - `./main.ts` - Main plugin file 14 | 15 | ### 🤝 Contributing 16 | 17 | Contributions are welcome, but please make sure they are understandable and no bloat. 18 | 19 | ### 🗺️ Roadmap 20 | 21 | ### Near future 22 | 23 | - [ ] become an official community plugin 24 | - [ ] await community feedback on the plugin (and fix bugs) 25 | -------------------------------------------------------------------------------- /docs/res/showcase.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexW00/obsidian-3d-graph/869a912f7199b3c6e6b02668398d32bc4be7e66b/docs/res/showcase.gif -------------------------------------------------------------------------------- /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 | esbuild.build({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ['src/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 | watch: !prod, 37 | target: 'es2018', 38 | logLevel: "info", 39 | sourcemap: prod ? false : 'inline', 40 | treeShaking: true, 41 | outfile: 'main.js', 42 | }).catch(() => process.exit(1)); 43 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "3d-graph", 3 | "name": "3D Graph", 4 | "version": "1.0.5", 5 | "description": "A 3D Graph for Obsidian", 6 | "author": "Alexander Weichart", 7 | "authorUrl": "https://github.com/AlexW00", 8 | "isDesktopOnly": false 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "3d-graph", 3 | "version": "1.0.5", 4 | "description": "A 3D graph for Obsidian", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "build:copy": "npm run build && ./copy_to_vault.sh ~/Desktop/YouTube", 10 | "version": "node version-bump.mjs && git add manifest.json versions.json" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/d3": "^7.4.0", 17 | "@types/node": "^16.11.6", 18 | "@typescript-eslint/eslint-plugin": "5.29.0", 19 | "@typescript-eslint/parser": "5.29.0", 20 | "builtin-modules": "3.3.0", 21 | "esbuild": "0.14.47", 22 | "obsidian": "latest", 23 | "tslib": "2.4.0", 24 | "typescript": "4.7.4" 25 | }, 26 | "dependencies": { 27 | "3d-force-graph": "^1.70.12", 28 | "d3": "^7.6.1", 29 | "observable-slim": "^0.1.6" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/graph/Graph.ts: -------------------------------------------------------------------------------- 1 | import Link from "./Link"; 2 | import Node from "./Node"; 3 | import { App } from "obsidian"; 4 | 5 | export default class Graph { 6 | public readonly nodes: Node[]; 7 | public readonly links: Link[]; 8 | 9 | // Indexes to quickly retrieve nodes and links by id 10 | private readonly nodeIndex: Map; 11 | private readonly linkIndex: Map>; 12 | 13 | constructor( 14 | nodes: Node[], 15 | links: Link[], 16 | nodeIndex: Map, 17 | linkIndex: Map> 18 | ) { 19 | this.nodes = nodes; 20 | this.links = links; 21 | this.nodeIndex = nodeIndex || new Map(); 22 | this.linkIndex = linkIndex || new Map>(); 23 | } 24 | 25 | // Returns a node by its id 26 | public getNodeById(id: string): Node | null { 27 | const index = this.nodeIndex.get(id); 28 | if (index !== undefined) { 29 | return this.nodes[index]; 30 | } 31 | return null; 32 | } 33 | 34 | // Returns a link by its source and target node ids 35 | public getLinkByIds( 36 | sourceNodeId: string, 37 | targetNodeId: string 38 | ): Link | null { 39 | const sourceLinkMap = this.linkIndex.get(sourceNodeId); 40 | if (sourceLinkMap) { 41 | const index = sourceLinkMap.get(targetNodeId); 42 | if (index !== undefined) { 43 | return this.links[index]; 44 | } 45 | } 46 | return null; 47 | } 48 | 49 | // Returns the outgoing links of a node 50 | public getLinksFromNode(sourceNodeId: string): Link[] { 51 | const sourceLinkMap = this.linkIndex.get(sourceNodeId); 52 | if (sourceLinkMap) { 53 | return Array.from(sourceLinkMap.values()).map( 54 | (index) => this.links[index] 55 | ); 56 | } 57 | return []; 58 | } 59 | 60 | // Returns the outgoing and incoming links of a node 61 | public getLinksWithNode(nodeId: string): Link[] { 62 | // we need to check if the link consists of a Node instance 63 | // instead of just a string id, 64 | // because D3 will replace each string id with the real Node instance 65 | // once the graph is rendered 66 | // @ts-ignore 67 | if (this.links[0]?.source?.id) { 68 | return this.links.filter( 69 | // @ts-ignore 70 | (link) => link.source.id === nodeId || link.target.id === nodeId 71 | ); 72 | } else { 73 | return this.links.filter( 74 | (link) => link.source === nodeId || link.target === nodeId 75 | ); 76 | } 77 | } 78 | 79 | // Returns the local graph of a node 80 | public getLocalGraph(nodeId: string): Graph { 81 | const node = this.getNodeById(nodeId); 82 | if (node) { 83 | const nodes = [node, ...node.neighbors]; 84 | const links: Link[] = []; 85 | const nodeIndex = new Map(); 86 | 87 | nodes.forEach((node, index) => { 88 | nodeIndex.set(node.id, index); 89 | }); 90 | 91 | nodes.forEach((node, index) => { 92 | const filteredLinks = node.links 93 | .filter( 94 | (link) => 95 | nodeIndex.has(link.target) && 96 | nodeIndex.has(link.source) 97 | ) 98 | .map((link) => { 99 | if ( 100 | !links.includes(link) && 101 | nodeIndex.has(link.target) && 102 | nodeIndex.has(link.source) 103 | ) 104 | links.push(link); 105 | return link; 106 | }); 107 | 108 | node.links.splice(0, node.links.length, ...filteredLinks); 109 | }); 110 | 111 | const linkIndex = Link.createLinkIndex(links); 112 | 113 | return new Graph(nodes, links, nodeIndex, linkIndex); 114 | } else { 115 | return new Graph([], [], new Map(), new Map()); 116 | } 117 | } 118 | 119 | // Clones the graph 120 | public clone = (): Graph => { 121 | return new Graph( 122 | structuredClone(this.nodes), 123 | structuredClone(this.links), 124 | structuredClone(this.nodeIndex), 125 | structuredClone(this.linkIndex) 126 | ); 127 | }; 128 | 129 | // Creates a graph using the Obsidian API 130 | public static createFromApp = (app: App): Graph => { 131 | const [nodes, nodeIndex] = Node.createFromFiles(app.vault.getFiles()), 132 | [links, linkIndex] = Link.createFromCache( 133 | app.metadataCache.resolvedLinks, 134 | nodes, 135 | nodeIndex 136 | ); 137 | return new Graph(nodes, links, nodeIndex, linkIndex); 138 | }; 139 | 140 | // updates this graph with new data from the Obsidian API 141 | public update = (app: App) => { 142 | const newGraph = Graph.createFromApp(app); 143 | 144 | this.nodes.splice(0, this.nodes.length, ...newGraph.nodes); 145 | this.links.splice(0, this.nodes.length, ...newGraph.links); 146 | 147 | this.nodeIndex.clear(); 148 | newGraph.nodeIndex.forEach((value, key) => { 149 | this.nodeIndex.set(key, value); 150 | }); 151 | 152 | this.linkIndex.clear(); 153 | newGraph.linkIndex.forEach((value, key) => { 154 | this.linkIndex.set(key, value); 155 | }); 156 | }; 157 | } 158 | -------------------------------------------------------------------------------- /src/graph/Link.ts: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | 3 | export type ResolvedLinkCache = Record>; 4 | 5 | export default class Link { 6 | public readonly source: string; 7 | public readonly target: string; 8 | public readonly linksAnAttachment: boolean; 9 | 10 | constructor(sourceId: string, targetId: string, linksAnAttachment: boolean) { 11 | this.source = sourceId; 12 | this.target = targetId; 13 | this.linksAnAttachment = linksAnAttachment; 14 | } 15 | 16 | // Creates a link index for an array of links 17 | static createLinkIndex(links: Link[]): Map> { 18 | const linkIndex = new Map>(); 19 | links.forEach((link, index) => { 20 | if (!linkIndex.has(link.source)) { 21 | linkIndex.set(link.source, new Map()); 22 | } 23 | linkIndex.get(link.source)?.set(link.target, index); 24 | }); 25 | 26 | return linkIndex; 27 | } 28 | 29 | // Creates an array of links + index from an array of nodes and the Obsidian API cache 30 | static createFromCache( 31 | cache: ResolvedLinkCache, 32 | nodes: Node[], 33 | nodeIndex: Map 34 | ): [Link[], Map>] { 35 | const links = Object.keys(cache) 36 | .map((node1Id) => { 37 | return Object.keys(cache[node1Id]) 38 | .map((node2Id) => { 39 | const [node1Index, node2Index] = [ 40 | nodeIndex.get(node1Id), 41 | nodeIndex.get(node2Id), 42 | ]; 43 | if ( 44 | node1Index !== undefined && 45 | node2Index !== undefined 46 | ) { 47 | return nodes[node1Index].addNeighbor( 48 | nodes[node2Index] 49 | ); 50 | } 51 | return null; 52 | }) 53 | .flat(); 54 | }) 55 | .flat() 56 | // remove duplicates and nulls 57 | .filter( 58 | (link, index, self) => 59 | link && 60 | link.source !== link.target && 61 | index === 62 | self.findIndex( 63 | (l: Link | null) => 64 | l && 65 | l.source === link.source && 66 | l.target === link.target 67 | ) 68 | ) as Link[]; 69 | 70 | return [links, Link.createLinkIndex(links)]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/graph/Node.ts: -------------------------------------------------------------------------------- 1 | import Link from "./Link"; 2 | import { TFile, getAllTags } from "obsidian"; 3 | 4 | export default class Node { 5 | public readonly id: string; 6 | public readonly name: string; 7 | public readonly path: string; 8 | public readonly isAttachment: boolean; 9 | public readonly val: number; // = weight, currently = 1 because scaling doesn't work well 10 | 11 | public readonly neighbors: Node[]; 12 | public readonly links: Link[]; 13 | public readonly tags: string[]; 14 | 15 | constructor( 16 | name: string, 17 | path: string, 18 | isAttachment: boolean, 19 | val = 10, 20 | neighbors: Node[] = [], 21 | links: Link[] = [], 22 | tags: string[] = [] 23 | ) { 24 | this.id = path; 25 | this.name = name; 26 | this.path = path; 27 | this.isAttachment = isAttachment; 28 | this.val = val; 29 | this.neighbors = neighbors; 30 | this.links = links; 31 | this.tags = tags; 32 | } 33 | 34 | // Creates an array of nodes from an array of files (from the Obsidian API) 35 | static createFromFiles(files: TFile[]): [Node[], Map] { 36 | const nodeMap = new Map(); 37 | return [ 38 | files 39 | .map((file, index) => { 40 | const node = new Node(file.name, file.path, file.extension == "md" ? false : true); 41 | const cache = app.metadataCache.getFileCache(file), 42 | tags = cache ? getAllTags(cache) : null; 43 | if (tags != null) { 44 | // stores tags without leading octothorpe `#` as ["tag1", "tag2", ...] 45 | tags.forEach((tag) => node.tags.push(tag.substring(1))); 46 | } 47 | if (!nodeMap.has(node.id)) { 48 | nodeMap.set(node.id, index); 49 | return node; 50 | } 51 | return null; 52 | }) 53 | .filter((node) => node !== null) as Node[], 54 | nodeMap, 55 | ]; 56 | } 57 | 58 | // Links together two nodes as neighbors (node -> neighbor) 59 | addNeighbor(neighbor: Node): Link | null { 60 | if (!this.isNeighborOf(neighbor)) { 61 | const link = new Link(this.id, neighbor.id, this.isAttachment || neighbor.isAttachment); 62 | this.neighbors.push(neighbor); 63 | this.addLink(link); 64 | 65 | neighbor.neighbors.push(this); 66 | neighbor.addLink(link); 67 | 68 | return link; 69 | } 70 | return null; 71 | } 72 | 73 | // Pushes a link to the node's links array if it doesn't already exist 74 | addLink(link: Link) { 75 | if ( 76 | !this.links.some( 77 | (l) => l.source === link.source && l.target === link.target 78 | ) 79 | ) { 80 | this.links.push(link); 81 | } 82 | } 83 | 84 | // Whether the node is a neighbor of another node 85 | public isNeighborOf(node: Node | string) { 86 | if (node instanceof Node) return this.neighbors.includes(node); 87 | else return this.neighbors.some((neighbor) => neighbor.id === node); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Plugin } from "obsidian"; 2 | import { Graph3dView } from "./views/graph/Graph3dView"; 3 | import GraphSettings from "./settings/GraphSettings"; 4 | import State from "./util/State"; 5 | import Graph from "./graph/Graph"; 6 | import ObsidianTheme from "./util/ObsidianTheme"; 7 | import EventBus from "./util/EventBus"; 8 | import { ResolvedLinkCache } from "./graph/Link"; 9 | import shallowCompare from "./util/ShallowCompare"; 10 | 11 | export default class Graph3dPlugin extends Plugin { 12 | _resolvedCache: ResolvedLinkCache; 13 | 14 | // States 15 | public settingsState: State; 16 | public openFileState: State = new State(undefined); 17 | private cacheIsReady: State = new State( 18 | this.app.metadataCache.resolvedLinks !== undefined 19 | ); 20 | 21 | // Other properties 22 | public globalGraph: Graph; 23 | public theme: ObsidianTheme; 24 | // Graphs that are waiting for cache to be ready 25 | private queuedGraphs: Graph3dView[] = []; 26 | private callbackUnregisterHandles: (() => void)[] = []; 27 | 28 | async onload() { 29 | await this.init(); 30 | this.addRibbonIcon("glasses", "3D Graph", this.openGlobalGraph); 31 | this.addCommand({ 32 | id: "open-3d-graph-global", 33 | name: "Open Global 3D Graph", 34 | callback: this.openGlobalGraph, 35 | }); 36 | 37 | this.addCommand({ 38 | id: "open-3d-graph-local", 39 | name: "Open Local 3D Graph", 40 | callback: this.openLocalGraph, 41 | }); 42 | } 43 | 44 | private async init() { 45 | await this.initStates(); 46 | this.initListeners(); 47 | } 48 | 49 | private async initStates() { 50 | const settings = await this.loadSettings(); 51 | this.settingsState = new State(settings); 52 | this.theme = new ObsidianTheme(this.app.workspace.containerEl); 53 | this.cacheIsReady.value = 54 | this.app.metadataCache.resolvedLinks !== undefined; 55 | this.onGraphCacheChanged(); 56 | } 57 | 58 | private initListeners() { 59 | this.callbackUnregisterHandles.push( 60 | // save settings on change 61 | this.settingsState.onChange(() => this.saveSettings()) 62 | ); 63 | 64 | // internal event to reset settings to default 65 | EventBus.on("do-reset-settings", this.onDoResetSettings); 66 | 67 | // show open local graph button in file menu 68 | this.registerEvent( 69 | this.app.workspace.on("file-menu", (menu, file) => { 70 | if (!file) return; 71 | menu.addItem((item) => { 72 | item.setTitle("Open in local 3D Graph") 73 | .setIcon("glasses") 74 | .onClick(() => this.openLocalGraph()); 75 | }); 76 | }) 77 | ); 78 | 79 | // when a file gets opened, update the open file state 80 | this.registerEvent( 81 | this.app.workspace.on("file-open", (file) => { 82 | if (file) this.openFileState.value = file.path; 83 | }) 84 | ); 85 | 86 | this.callbackUnregisterHandles.push( 87 | // when the cache is ready, open the queued graphs 88 | this.cacheIsReady.onChange((isReady) => { 89 | if (isReady) { 90 | this.openQueuedGraphs(); 91 | } 92 | }) 93 | ); 94 | 95 | // all files are resolved, so the cache is ready: 96 | this.app.metadataCache.on( 97 | "resolved", 98 | this.onGraphCacheReady.bind(this) 99 | ); 100 | // the cache changed: 101 | this.app.metadataCache.on( 102 | "resolve", 103 | this.onGraphCacheChanged.bind(this) 104 | ); 105 | } 106 | 107 | // opens all queued graphs (graphs get queued if cache isnt ready yet) 108 | private openQueuedGraphs() { 109 | this.queuedGraphs.forEach((view) => view.showGraph()); 110 | this.queuedGraphs = []; 111 | } 112 | 113 | private onGraphCacheReady = () => { 114 | console.log("Graph cache is ready"); 115 | this.cacheIsReady.value = true; 116 | this.onGraphCacheChanged(); 117 | }; 118 | 119 | private onGraphCacheChanged = () => { 120 | // check if the cache actually updated 121 | // Obsidian API sends a lot of (for this plugin) unnecessary stuff 122 | // with the resolve event 123 | if ( 124 | this.cacheIsReady.value && 125 | !shallowCompare( 126 | this._resolvedCache, 127 | this.app.metadataCache.resolvedLinks 128 | ) 129 | ) { 130 | this._resolvedCache = structuredClone( 131 | this.app.metadataCache.resolvedLinks 132 | ); 133 | this.globalGraph = Graph.createFromApp(this.app); 134 | } else { 135 | console.log( 136 | "changed but ", 137 | this.cacheIsReady.value, 138 | " and ", 139 | shallowCompare( 140 | this._resolvedCache, 141 | this.app.metadataCache.resolvedLinks 142 | ) 143 | ); 144 | } 145 | }; 146 | 147 | private onDoResetSettings = () => { 148 | this.settingsState.value.reset(); 149 | EventBus.trigger("did-reset-settings"); 150 | }; 151 | 152 | // Opens a local graph view in a new leaf 153 | private openLocalGraph = () => { 154 | const newFilePath = this.app.workspace.getActiveFile()?.path; 155 | 156 | if (newFilePath) { 157 | this.openFileState.value = newFilePath; 158 | this.openGraph(true); 159 | } else { 160 | new Notice("No file is currently open"); 161 | } 162 | }; 163 | 164 | // Opens a global graph view in the current leaf 165 | private openGlobalGraph = () => { 166 | this.openGraph(false); 167 | }; 168 | 169 | // Open a global or local graph 170 | private openGraph = (isLocalGraph: boolean) => { 171 | const leaf = this.app.workspace.getLeaf(isLocalGraph ? "split" : false); 172 | const graphView = new Graph3dView(this, leaf, isLocalGraph); 173 | leaf.open(graphView); 174 | if (this.cacheIsReady.value) { 175 | graphView.showGraph(); 176 | } else { 177 | this.queuedGraphs.push(graphView); 178 | } 179 | }; 180 | 181 | private async loadSettings(): Promise { 182 | const loadedData = await this.loadData(), 183 | settings = GraphSettings.fromStore(loadedData); 184 | return settings; 185 | } 186 | 187 | async saveSettings() { 188 | console.log( 189 | "saveSettings:", 190 | this.settingsState.getRawValue().toObject() 191 | ); 192 | await this.saveData(this.settingsState.getRawValue().toObject()); 193 | } 194 | 195 | onunload() { 196 | super.onunload(); 197 | this.callbackUnregisterHandles.forEach((handle) => handle()); 198 | EventBus.off("do-reset-settings", this.onDoResetSettings); 199 | } 200 | 201 | public getSettings(): GraphSettings { 202 | return this.settingsState.value; 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/settings/GraphSettings.ts: -------------------------------------------------------------------------------- 1 | import { DisplaySettings } from "./categories/DisplaySettings"; 2 | import { FilterSettings } from "./categories/FilterSettings"; 3 | import { GroupSettings } from "./categories/GroupSettings"; 4 | 5 | export default class GraphSettings { 6 | filters: FilterSettings; 7 | groups: GroupSettings; 8 | display: DisplaySettings; 9 | 10 | constructor( 11 | filterOptions: FilterSettings, 12 | groupOptions: GroupSettings, 13 | displayOptions: DisplaySettings 14 | ) { 15 | this.filters = filterOptions; 16 | this.groups = groupOptions; 17 | this.display = displayOptions; 18 | } 19 | 20 | public static fromStore(store: any) { 21 | return new GraphSettings( 22 | FilterSettings.fromStore(store?.filters), 23 | GroupSettings.fromStore(store?.groups), 24 | DisplaySettings.fromStore(store?.display) 25 | ); 26 | } 27 | 28 | public reset() { 29 | Object.assign(this.filters, new FilterSettings()); 30 | Object.assign(this.groups, new GroupSettings()); 31 | Object.assign(this.display, new DisplaySettings()); 32 | } 33 | 34 | public toObject() { 35 | return { 36 | filters: this.filters.toObject(), 37 | groups: this.groups.toObject(), 38 | display: this.display.toObject(), 39 | }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/settings/categories/DisplaySettings.ts: -------------------------------------------------------------------------------- 1 | export class DisplaySettings { 2 | nodeSize = 4; 3 | linkThickness = 5; 4 | particleSize = 6; 5 | particleCount = 4; 6 | 7 | constructor( 8 | nodeSize?: number, 9 | linkThickness?: number, 10 | particleSize?: number, 11 | particleCount?: number 12 | ) { 13 | this.nodeSize = nodeSize ?? this.nodeSize; 14 | this.linkThickness = linkThickness ?? this.linkThickness; 15 | this.particleSize = particleSize ?? this.particleSize; 16 | this.particleCount = particleCount ?? this.particleCount; 17 | } 18 | 19 | public static fromStore(store: any) { 20 | return new DisplaySettings( 21 | store?.nodeSize, 22 | store?.linkThickness, 23 | store?.particleSize, 24 | store?.particleCount 25 | ); 26 | } 27 | 28 | public toObject() { 29 | return { 30 | nodeSize: this.nodeSize, 31 | linkThickness: this.linkThickness, 32 | particleSize: this.particleSize, 33 | particleCount: this.particleCount, 34 | }; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/settings/categories/FilterSettings.ts: -------------------------------------------------------------------------------- 1 | export class FilterSettings { 2 | doShowOrphans? = true; 3 | doShowAttachments? = false; 4 | 5 | constructor(doShowOrphans?: boolean, doShowAttachments?: boolean) { 6 | this.doShowOrphans = doShowOrphans ?? this.doShowOrphans; 7 | this.doShowAttachments = doShowAttachments ?? this.doShowAttachments; 8 | } 9 | 10 | public static fromStore(store: any) { 11 | return new FilterSettings(store?.doShowOrphans, store?.doShowAttachments); 12 | } 13 | 14 | public toObject() { 15 | return { 16 | doShowOrphans: this.doShowOrphans, 17 | doShowAttachments: this.doShowAttachments, 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/settings/categories/GroupSettings.ts: -------------------------------------------------------------------------------- 1 | import Node from "../../graph/Node"; 2 | 3 | export class GroupSettings { 4 | groups: NodeGroup[] = []; 5 | 6 | constructor(groups?: NodeGroup[]) { 7 | this.groups = groups ?? this.groups; 8 | } 9 | 10 | public static fromStore(store: any) { 11 | return new GroupSettings( 12 | store?.groups.flatMap((nodeGroup: any) => { 13 | return new NodeGroup(nodeGroup.query, nodeGroup.color); 14 | }) 15 | ) 16 | } 17 | 18 | public toObject() { 19 | return { 20 | groups: this.groups, 21 | }; 22 | } 23 | } 24 | 25 | export class NodeGroup { 26 | query: string; 27 | color: string; 28 | 29 | constructor(query: string, color: string) { 30 | this.query = query; 31 | this.color = color; 32 | } 33 | 34 | static getRegex(query: string): RegExp { 35 | return new RegExp(query); 36 | } 37 | 38 | static matches(query: string, node: Node): boolean { 39 | // queries tags if query begins with "tag:" or "tag:#" 40 | if (query.match(/^tag:#?/)) { 41 | return node.tags.includes(query.replace(/^tag:#?/, "")) 42 | } 43 | return node.path.startsWith(this.sanitizeQuery(query)) 44 | } 45 | 46 | static sanitizeQuery(query: string): string { 47 | const trimmedQuery = query.trim(); 48 | if (trimmedQuery.startsWith("./")) return trimmedQuery.slice(1); 49 | else return trimmedQuery; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/util/EventBus.ts: -------------------------------------------------------------------------------- 1 | import { Events } from "obsidian"; 2 | 3 | // Event bus for internal Plugin communication 4 | class EventBus extends Events { 5 | constructor() { 6 | super(); 7 | } 8 | } 9 | 10 | export default new EventBus(); 11 | -------------------------------------------------------------------------------- /src/util/ObsidianTheme.ts: -------------------------------------------------------------------------------- 1 | // Helper to access the current theme in TS 2 | export default class ObsidianTheme { 3 | backgroundPrimary: string; 4 | backgroundPrimaryAlt: string; 5 | backgroundSecondary: string; 6 | backgroundSecondaryAlt: string; 7 | 8 | backgroundModifierBorder: string; 9 | backgroundModifierSuccess: string; 10 | backgroundModifierError: string; 11 | 12 | colorAccent: string; 13 | interactiveAccentHover: string; 14 | 15 | textNormal: string; 16 | textMuted: string; 17 | textFaint: string; 18 | 19 | textAccent: string; 20 | 21 | // some others missing, but not needed currently 22 | 23 | constructor(root: HTMLElement) { 24 | this.backgroundPrimary = getComputedStyle(root) 25 | .getPropertyValue("--background-primary") 26 | .trim(); 27 | this.backgroundPrimaryAlt = getComputedStyle(root) 28 | .getPropertyValue("--background-primary-alt") 29 | .trim(); 30 | this.backgroundSecondary = getComputedStyle(root) 31 | .getPropertyValue("--background-secondary") 32 | .trim(); 33 | this.backgroundSecondaryAlt = getComputedStyle(root) 34 | .getPropertyValue("--background-secondary-alt") 35 | .trim(); 36 | 37 | this.backgroundModifierBorder = getComputedStyle(root) 38 | .getPropertyValue("--background-modifier-border") 39 | .trim(); 40 | this.backgroundModifierSuccess = getComputedStyle(root) 41 | .getPropertyValue("--background-modifier-success") 42 | .trim(); 43 | this.backgroundModifierError = getComputedStyle(root) 44 | .getPropertyValue("--background-modifier-error") 45 | .trim(); 46 | 47 | this.colorAccent = getComputedStyle(root) 48 | .getPropertyValue("--color-accent") 49 | .trim(); 50 | 51 | this.textNormal = getComputedStyle(root) 52 | .getPropertyValue("--text-normal") 53 | .trim(); 54 | this.textMuted = getComputedStyle(root) 55 | .getPropertyValue("--text-muted") 56 | .trim(); 57 | this.textFaint = getComputedStyle(root) 58 | .getPropertyValue("--text-faint") 59 | .trim(); 60 | 61 | this.textAccent = getComputedStyle(root) 62 | .getPropertyValue("--text-accent") 63 | .trim(); 64 | this.interactiveAccentHover = getComputedStyle(root) 65 | .getPropertyValue("--interactive-accent-hover") 66 | .trim(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/util/ShallowCompare.ts: -------------------------------------------------------------------------------- 1 | // Shallow compare for nested objects 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | const shallowCompare = (obj1: any, obj2: any): boolean => { 4 | if (!obj1 || !obj2) return obj1 == obj2; 5 | else if (obj1 instanceof Object && obj2 instanceof Object) { 6 | return ( 7 | Object.keys(obj1).length === Object.keys(obj2).length && 8 | Object.keys(obj1).every( 9 | (key) => 10 | obj2.hasOwnProperty(key) && 11 | shallowCompare(obj1[key], obj2[key]) 12 | ) 13 | ); 14 | } else return obj1 == obj2; 15 | }; 16 | 17 | export default shallowCompare; 18 | -------------------------------------------------------------------------------- /src/util/State.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import ObservableSlim from "observable-slim"; 3 | 4 | // ====================================================== // 5 | // ====================== State ====================== // 6 | // ====================================================== // 7 | 8 | // Wrapper class to make any object/primitive observable 9 | 10 | export type StateListener = (changeData: StateChange) => void; 11 | 12 | export default class State { 13 | private readonly listeners = new Map(); 14 | private static listener_count = 0; 15 | 16 | private val: ProxyConstructor | T; 17 | private static stateCount = 0; 18 | readonly id: number; 19 | 20 | constructor(value: T) { 21 | State.stateCount++; 22 | this.id = State.stateCount; 23 | 24 | this.val = 25 | typeof value === "object" 26 | ? ObservableSlim.create(value, false, this.onValueChange) 27 | : value; 28 | } 29 | 30 | get value(): T { 31 | return this.val as T; 32 | } 33 | 34 | set value(val: T) { 35 | const previousValue = this.val; 36 | if (typeof val !== "object") { 37 | this.val = val; 38 | } else { 39 | this.val = ObservableSlim.create(val, false, this.onValueChange); 40 | } 41 | this.onValueChange([ 42 | { 43 | type: "update", 44 | property: "", 45 | currentPath: "", 46 | jsonPointer: "", 47 | target: this.val, 48 | // @ts-ignore 49 | proxy: (this.val as never).__getProxy, 50 | previousValue, 51 | newValue: this.val, 52 | }, 53 | ]); 54 | } 55 | 56 | public onChange = ( 57 | callback: (change: StateChange) => void 58 | ): (() => void) => { 59 | const listenerId = this.generateListenerId(); 60 | this.listeners.set(listenerId, callback); 61 | return () => this.unsubscribe(listenerId); // return unsubscribe function 62 | }; 63 | 64 | public createSubState( 65 | key: string, 66 | type: new (...a: never) => S 67 | ): State { 68 | const subStateKeys = key.split("."), 69 | subStateValue: S = subStateKeys.reduce((obj: any, key: string) => { 70 | const val = obj[key]; 71 | if (val !== undefined) { 72 | return val; 73 | } 74 | throw new InvalidStateKeyError(key, this); 75 | }, this); 76 | if (typeof subStateValue === "object") { 77 | // check if is like generic type S 78 | if (subStateValue instanceof type) { 79 | // @ts-ignore 80 | return new State(subStateValue.__getTarget); 81 | } else { 82 | throw new Error( 83 | `Substate ${key} of state ${this.id} is not of type ${type.name}` 84 | ); 85 | } 86 | } else 87 | throw new Error( 88 | "SubStates of properties that are Primitives are not supported yet." 89 | ); 90 | } 91 | 92 | public getRawValue(): T { 93 | if (typeof this.val === "object") { 94 | // @ts-ignore 95 | return (this.val as unknown as ProxyConstructor).__getTarget; 96 | } 97 | return this.val as T; 98 | } 99 | 100 | private generateListenerId = () => { 101 | State.listener_count++; 102 | return State.listener_count; 103 | }; 104 | 105 | private unsubscribe = (listenerId: number) => { 106 | this.listeners.delete(listenerId); 107 | }; 108 | 109 | private notifyAll = (changeData: StateChange) => { 110 | this.listeners.forEach((listener) => listener(changeData)); 111 | }; 112 | 113 | private onValueChange = (changes: StateChange[]) => { 114 | changes.forEach((change) => { 115 | this.notifyAll( 116 | Object.assign({}, change, { triggerStateId: this.id }) 117 | ); 118 | }); 119 | }; 120 | } 121 | 122 | // custom error type for invalid state keys 123 | export class InvalidStateKeyError extends Error { 124 | constructor(subStateKey: string, state: State) { 125 | super(); 126 | this.message = `Key does not exist! 127 | Detailed error: 128 | ${subStateKey} could not be found in "value":${JSON.stringify(state.value)} 129 | `; 130 | } 131 | } 132 | 133 | export interface StateChange { 134 | type: "add" | "delete" | "update"; 135 | property: string; // equals "value" if the whole state is changed 136 | 137 | currentPath: string; // path of the property 138 | jsonPointer: string; // path as json pointer syntax 139 | target: any; // the target object 140 | proxy?: ProxyConstructor; // the proxy of the object 141 | 142 | previousValue?: any; // may be undefined if the property is new 143 | newValue?: any; // may be undefined if the property is deleted 144 | } 145 | -------------------------------------------------------------------------------- /src/views/atomics/ColorPicker.ts: -------------------------------------------------------------------------------- 1 | const ColorPicker = (containerEl: HTMLElement, value: string, onChange: (value: string) => void) => { 2 | const input = document.createElement("input"); 3 | input.type = "color"; 4 | input.value = value; 5 | input.addEventListener("change", () => { 6 | onChange(input.value); 7 | }); 8 | containerEl.appendChild(input); 9 | } 10 | 11 | export default ColorPicker; 12 | -------------------------------------------------------------------------------- /src/views/atomics/SimpleSliderSetting.ts: -------------------------------------------------------------------------------- 1 | import {Setting} from "obsidian"; 2 | 3 | const SimpleSliderSetting = (containerEl: HTMLElement, options: SliderOptions, onChange: (newValue: number) => void) => { 4 | const slider = new Setting(containerEl) 5 | .setName(options.name) 6 | .setClass("mod-slider") 7 | .addSlider( 8 | (slider) => { 9 | slider.setLimits(options.stepOptions.min, options.stepOptions.max, options.stepOptions.step) 10 | .setValue(options.value) 11 | .onChange(async (value) => { 12 | onChange(value); 13 | }); 14 | } 15 | ) 16 | return slider; 17 | } 18 | 19 | export interface SliderOptions { 20 | name: string; 21 | stepOptions: SliderStepOptions; 22 | value: number; 23 | } 24 | 25 | export interface SliderStepOptions { 26 | min: number; 27 | max: number; 28 | step: number; 29 | } 30 | 31 | export const DEFAULT_SLIDER_STEP_OPTIONS: SliderStepOptions = { 32 | min: 1, 33 | max: 20, 34 | step: 1, 35 | } 36 | 37 | export default SimpleSliderSetting; 38 | -------------------------------------------------------------------------------- /src/views/atomics/TreeItem.ts: -------------------------------------------------------------------------------- 1 | export type HtmlBuilder = (containerEl: HTMLElement) => void; 2 | 3 | // Collapsable tree item, imitates Obsidian's tree items 4 | export class TreeItem extends HTMLDivElement { 5 | private readonly $inner: HTMLElement; 6 | private readonly childrenBuilders: HtmlBuilder[]; 7 | 8 | constructor($inner: HTMLElement, children: HtmlBuilder[]) { 9 | super(); 10 | this.$inner = $inner; 11 | this.childrenBuilders = children; 12 | } 13 | 14 | async connectedCallback() { 15 | this.appendSelf(); 16 | this.appendChildren(); 17 | } 18 | 19 | private appendSelf = () => { 20 | ["graph-control-section", "tree-item"].forEach((className) => 21 | this.classList.add(className) 22 | ); 23 | 24 | const $self = createDiv({ cls: "tree-item-self" }); 25 | 26 | $self.addEventListener("click", () => { 27 | this.toggleCollapse(); 28 | }); 29 | 30 | const $inner = createDiv({ cls: "tree-item-inner" }); 31 | $inner.append(this.$inner); 32 | $self.append($inner); 33 | this.append($self); 34 | }; 35 | 36 | private appendChildren = () => { 37 | const $children = createDiv({ cls: "tree-item-children" }); 38 | this.childrenBuilders.forEach((build: HtmlBuilder) => build($children)); 39 | this.append($children); 40 | }; 41 | 42 | private toggleCollapse = (doCollapse?: boolean) => { 43 | if (doCollapse === undefined) { 44 | doCollapse = !this.classList.contains("is-collapsed"); 45 | } 46 | this.classList.toggle("is-collapsed", doCollapse); 47 | }; 48 | } 49 | 50 | if (typeof customElements.get("tree-item") === "undefined") { 51 | customElements.define("tree-item", TreeItem, { extends: "div" }); 52 | } 53 | -------------------------------------------------------------------------------- /src/views/graph/ForceGraph.ts: -------------------------------------------------------------------------------- 1 | import ForceGraph3D, { ForceGraph3DInstance } from "3d-force-graph"; 2 | import Node from "../../graph/Node"; 3 | import Link from "../../graph/Link"; 4 | import { StateChange } from "../../util/State"; 5 | import Graph3dPlugin from "../../main"; 6 | import Graph from "../../graph/Graph"; 7 | import { NodeGroup } from "../../settings/categories/GroupSettings"; 8 | import { rgba } from "polished"; 9 | import EventBus from "../../util/EventBus"; 10 | 11 | // Adapted from https://github.com/vasturiano/3d-force-graph/blob/master/example/highlight/index.html 12 | // D3.js 3D Force Graph 13 | 14 | export class ForceGraph { 15 | private instance: ForceGraph3DInstance; 16 | private readonly rootHtmlElement: HTMLElement; 17 | 18 | private readonly highlightedNodes: Set = new Set(); 19 | private readonly highlightedLinks: Set = new Set(); 20 | hoveredNode: Node | null; 21 | 22 | private readonly isLocalGraph: boolean; 23 | private graph: Graph; 24 | private readonly plugin: Graph3dPlugin; 25 | 26 | constructor( 27 | plugin: Graph3dPlugin, 28 | rootHtmlElement: HTMLElement, 29 | isLocalGraph: boolean 30 | ) { 31 | this.rootHtmlElement = rootHtmlElement; 32 | this.isLocalGraph = isLocalGraph; 33 | this.plugin = plugin; 34 | 35 | console.log("ForceGraph constructor", rootHtmlElement); 36 | 37 | this.createGraph(); 38 | this.initListeners(); 39 | } 40 | 41 | private initListeners() { 42 | this.plugin.settingsState.onChange(this.onSettingsStateChanged); 43 | if (this.isLocalGraph) 44 | this.plugin.openFileState.onChange(this.refreshGraphData); 45 | EventBus.on("graph-changed", this.refreshGraphData); 46 | } 47 | 48 | private createGraph() { 49 | this.createInstance(); 50 | this.createNodes(); 51 | this.createLinks(); 52 | } 53 | 54 | private createInstance() { 55 | const [width, height] = [ 56 | this.rootHtmlElement.innerWidth, 57 | this.rootHtmlElement.innerHeight, 58 | ]; 59 | this.instance = ForceGraph3D()(this.rootHtmlElement) 60 | .graphData(this.getGraphData()) 61 | .nodeLabel( 62 | (node: Node) => `
${node.name}
` 63 | ) 64 | .nodeRelSize(this.plugin.getSettings().display.nodeSize) 65 | .backgroundColor(rgba(0, 0, 0, 0.0)) 66 | .width(width) 67 | .height(height); 68 | } 69 | 70 | private getGraphData = (): Graph => { 71 | if (this.isLocalGraph && this.plugin.openFileState.value) { 72 | this.graph = this.plugin.globalGraph 73 | .clone() 74 | .getLocalGraph(this.plugin.openFileState.value); 75 | console.log(this.graph); 76 | } else { 77 | this.graph = this.plugin.globalGraph.clone(); 78 | } 79 | 80 | return this.graph; 81 | }; 82 | 83 | private refreshGraphData = () => { 84 | this.instance.graphData(this.getGraphData()); 85 | }; 86 | 87 | private onSettingsStateChanged = (data: StateChange) => { 88 | if (data.currentPath === "display.nodeSize") { 89 | this.instance.nodeRelSize(data.newValue); 90 | } else if (data.currentPath === "display.linkWidth") { 91 | this.instance.linkWidth(data.newValue); 92 | } else if (data.currentPath === "display.particleSize") { 93 | this.instance.linkDirectionalParticleWidth( 94 | this.plugin.getSettings().display.particleSize 95 | ); 96 | } 97 | 98 | this.instance.refresh(); // other settings only need a refresh 99 | }; 100 | 101 | public updateDimensions() { 102 | const [width, height] = [ 103 | this.rootHtmlElement.offsetWidth, 104 | this.rootHtmlElement.offsetHeight, 105 | ]; 106 | this.setDimensions(width, height); 107 | } 108 | 109 | public setDimensions(width: number, height: number) { 110 | this.instance.width(width); 111 | this.instance.height(height); 112 | } 113 | 114 | private createNodes = () => { 115 | this.instance 116 | .nodeColor((node: Node) => this.getNodeColor(node)) 117 | .nodeVisibility(this.doShowNode) 118 | .onNodeHover(this.onNodeHover); 119 | }; 120 | 121 | private getNodeColor = (node: Node): string => { 122 | if (this.isHighlightedNode(node)) { 123 | // Node is highlighted 124 | return node === this.hoveredNode 125 | ? this.plugin.theme.interactiveAccentHover 126 | : this.plugin.theme.textAccent; 127 | } else { 128 | let color = this.plugin.theme.textMuted; 129 | this.plugin.getSettings().groups.groups.forEach((group) => { 130 | // multiple groups -> last match wins 131 | if (NodeGroup.matches(group.query, node)) color = group.color; 132 | }); 133 | return color; 134 | } 135 | }; 136 | 137 | private doShowNode = (node: Node) => { 138 | return ( 139 | (this.plugin.getSettings().filters.doShowOrphans || 140 | node.links.length > 0) && 141 | (this.plugin.getSettings().filters.doShowAttachments || 142 | !node.isAttachment) 143 | ); 144 | }; 145 | 146 | private doShowLink = (link: Link) => { 147 | return this.plugin.getSettings().filters.doShowAttachments || !link.linksAnAttachment 148 | } 149 | 150 | private onNodeHover = (node: Node | null) => { 151 | if ( 152 | (!node && !this.highlightedNodes.size) || 153 | (node && this.hoveredNode === node) 154 | ) 155 | return; 156 | 157 | this.clearHighlights(); 158 | 159 | if (node) { 160 | this.highlightedNodes.add(node.id); 161 | node.neighbors.forEach((neighbor) => 162 | this.highlightedNodes.add(neighbor.id) 163 | ); 164 | const nodeLinks = this.graph.getLinksWithNode(node.id); 165 | 166 | if (nodeLinks) 167 | nodeLinks.forEach((link) => this.highlightedLinks.add(link)); 168 | } 169 | this.hoveredNode = node ?? null; 170 | this.updateHighlight(); 171 | }; 172 | 173 | private isHighlightedLink = (link: Link): boolean => { 174 | return this.highlightedLinks.has(link); 175 | }; 176 | 177 | private isHighlightedNode = (node: Node): boolean => { 178 | return this.highlightedNodes.has(node.id); 179 | }; 180 | 181 | private createLinks = () => { 182 | this.instance 183 | .linkWidth((link: Link) => 184 | this.isHighlightedLink(link) 185 | ? this.plugin.getSettings().display.linkThickness * 1.5 186 | : this.plugin.getSettings().display.linkThickness 187 | ) 188 | .linkDirectionalParticles((link: Link) => 189 | this.isHighlightedLink(link) 190 | ? this.plugin.getSettings().display.particleCount 191 | : 0 192 | ) 193 | .linkDirectionalParticleWidth( 194 | this.plugin.getSettings().display.particleSize 195 | ) 196 | .linkVisibility(this.doShowLink) 197 | .onLinkHover(this.onLinkHover) 198 | .linkColor((link: Link) => 199 | this.isHighlightedLink(link) 200 | ? this.plugin.theme.textAccent 201 | : this.plugin.theme.textMuted 202 | ); 203 | }; 204 | 205 | private onLinkHover = (link: Link | null) => { 206 | this.clearHighlights(); 207 | 208 | if (link) { 209 | this.highlightedLinks.add(link); 210 | this.highlightedNodes.add(link.source); 211 | this.highlightedNodes.add(link.target); 212 | } 213 | this.updateHighlight(); 214 | }; 215 | 216 | private clearHighlights = () => { 217 | this.highlightedNodes.clear(); 218 | this.highlightedLinks.clear(); 219 | }; 220 | 221 | private updateHighlight() { 222 | // trigger update of highlighted objects in scene 223 | this.instance 224 | .nodeColor(this.instance.nodeColor()) 225 | .linkColor(this.instance.linkColor()) 226 | .linkDirectionalParticles(this.instance.linkDirectionalParticles()); 227 | } 228 | 229 | getInstance(): ForceGraph3DInstance { 230 | return this.instance; 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/views/graph/Graph3dView.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf } from "obsidian"; 2 | import Node from "../../graph/Node"; 3 | import { ForceGraph } from "./ForceGraph"; 4 | import { GraphSettingsView } from "../settings/GraphSettingsView"; 5 | import Graph3dPlugin from "src/main"; 6 | 7 | export class Graph3dView extends ItemView { 8 | private forceGraph: ForceGraph; 9 | private readonly isLocalGraph: boolean; 10 | private readonly plugin: Graph3dPlugin; 11 | 12 | constructor( 13 | plugin: Graph3dPlugin, 14 | leaf: WorkspaceLeaf, 15 | isLocalGraph = false 16 | ) { 17 | super(leaf); 18 | this.isLocalGraph = isLocalGraph; 19 | this.plugin = plugin; 20 | } 21 | 22 | onunload() { 23 | super.onunload(); 24 | this.forceGraph?.getInstance()._destructor(); 25 | } 26 | 27 | showGraph() { 28 | const viewContent = this.containerEl.querySelector( 29 | ".view-content" 30 | ) as HTMLElement; 31 | 32 | if (viewContent) { 33 | viewContent.classList.add("graph-3d-view"); 34 | this.appendGraph(viewContent); 35 | const settings = new GraphSettingsView( 36 | this.plugin.settingsState, 37 | this.plugin.theme 38 | ); 39 | viewContent.appendChild(settings); 40 | } else { 41 | console.error("Could not find view content"); 42 | } 43 | } 44 | 45 | getDisplayText(): string { 46 | return "3D-Graph"; 47 | } 48 | 49 | getViewType(): string { 50 | return "3d_graph_view"; 51 | } 52 | 53 | onResize() { 54 | super.onResize(); 55 | this.forceGraph.updateDimensions(); 56 | } 57 | 58 | private appendGraph(viewContent: HTMLElement) { 59 | this.forceGraph = new ForceGraph( 60 | this.plugin, 61 | viewContent, 62 | this.isLocalGraph 63 | ); 64 | 65 | this.forceGraph 66 | .getInstance() 67 | .onNodeClick((node: Node, mouseEvent: MouseEvent) => { 68 | const clickedNodeFile = this.app.vault 69 | .getFiles() 70 | .find((f) => f.path === node.path); 71 | 72 | if (clickedNodeFile) { 73 | if (this.isLocalGraph) { 74 | this.app.workspace 75 | .getLeaf(false) 76 | .openFile(clickedNodeFile); 77 | } else { 78 | this.leaf.openFile(clickedNodeFile); 79 | } 80 | } 81 | }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/views/settings/GraphSettingsView.ts: -------------------------------------------------------------------------------- 1 | import { TreeItem } from "../atomics/TreeItem"; 2 | import DisplaySettingsView from "./categories/DisplaySettingsView"; 3 | import { FilterSettings } from "../../settings/categories/FilterSettings"; 4 | import { GroupSettings } from "../../settings/categories/GroupSettings"; 5 | import { DisplaySettings } from "../../settings/categories/DisplaySettings"; 6 | import { ExtraButtonComponent } from "obsidian"; 7 | import State, { StateChange } from "../../util/State"; 8 | import EventBus from "../../util/EventBus"; 9 | import GroupSettingsView from "./categories/GroupSettingsView"; 10 | import FilterSettingsView from "./categories/FilterSettingsView"; 11 | import GraphSettings from "src/settings/GraphSettings"; 12 | import ObsidianTheme from "src/util/ObsidianTheme"; 13 | 14 | export class GraphSettingsView extends HTMLDivElement { 15 | private settingsButton: ExtraButtonComponent; 16 | private graphControls: HTMLDivElement; 17 | private readonly settingsState: State; 18 | private readonly theme: ObsidianTheme; 19 | 20 | constructor(settingsState: State, theme: ObsidianTheme) { 21 | super(); 22 | this.settingsState = settingsState; 23 | this.theme = theme; 24 | } 25 | 26 | private isCollapsedState = new State(true); 27 | 28 | private callbackUnregisterHandles: (() => void)[] = []; 29 | 30 | async connectedCallback() { 31 | this.classList.add("graph-settings-view"); 32 | 33 | this.settingsButton = new ExtraButtonComponent(this) 34 | .setIcon("settings") 35 | .setTooltip("Open graph settings") 36 | .onClick(this.onSettingsButtonClicked); 37 | 38 | this.graphControls = this.createDiv({ cls: "graph-controls" }); 39 | 40 | this.appendGraphControlsItems( 41 | this.graphControls.createDiv({ cls: "control-buttons" }) 42 | ); 43 | 44 | this.appendSetting( 45 | this.settingsState.createSubState("value.filters", FilterSettings), 46 | "Filters", 47 | FilterSettingsView 48 | ); 49 | this.appendSetting( 50 | this.settingsState.createSubState("value.groups", GroupSettings), 51 | "Groups", 52 | (...args) => GroupSettingsView(...args, this.theme) 53 | ); 54 | this.appendSetting( 55 | this.settingsState.createSubState("value.display", DisplaySettings), 56 | "Display", 57 | DisplaySettingsView 58 | ); 59 | this.initListeners(); 60 | this.toggleCollapsed(this.isCollapsedState.value); 61 | } 62 | 63 | private initListeners() { 64 | EventBus.on("did-reset-settings", () => { 65 | // Re append all settings 66 | this.disconnectedCallback(); 67 | this.connectedCallback(); 68 | }); 69 | this.callbackUnregisterHandles.push( 70 | this.isCollapsedState.onChange(this.onIsCollapsedChanged) 71 | ); 72 | } 73 | 74 | // clicked to collapse/expand 75 | private onIsCollapsedChanged = (stateChange: StateChange) => { 76 | const collapsed = stateChange.newValue; 77 | this.toggleCollapsed(collapsed); 78 | }; 79 | 80 | // toggle the view to collapsed or expanded 81 | private toggleCollapsed(collapsed: boolean) { 82 | if (collapsed) { 83 | this.settingsButton.setDisabled(false); 84 | this.graphControls.classList.add("hidden"); 85 | } else { 86 | this.settingsButton.setDisabled(true); 87 | this.graphControls.classList.remove("hidden"); 88 | } 89 | } 90 | 91 | private onSettingsButtonClicked = () => { 92 | console.log("settings button clicked"); 93 | this.isCollapsedState.value = !this.isCollapsedState.value; 94 | }; 95 | 96 | private appendGraphControlsItems(containerEl: HTMLElement) { 97 | this.appendResetButton(containerEl); 98 | this.appendMinimizeButton(containerEl); 99 | } 100 | 101 | private appendResetButton(containerEl: HTMLElement) { 102 | new ExtraButtonComponent(containerEl) 103 | .setIcon("eraser") 104 | .setTooltip("Reset to default") 105 | .onClick(() => EventBus.trigger("do-reset-settings")); 106 | } 107 | 108 | private appendMinimizeButton(containerEl: HTMLElement) { 109 | new ExtraButtonComponent(containerEl) 110 | .setIcon("x") 111 | .setTooltip("Close") 112 | .onClick(() => (this.isCollapsedState.value = true)); 113 | } 114 | 115 | // utility function to append a setting 116 | private appendSetting( 117 | setting: S, 118 | title: string, 119 | view: (setting: S, containerEl: HTMLElement) => void 120 | ) { 121 | const header = document.createElement("header"); 122 | header.classList.add("graph-control-section-header"); 123 | header.innerHTML = title; 124 | const item = new TreeItem(header, [ 125 | (containerEl: HTMLElement) => view(setting, containerEl), 126 | ]); 127 | item.classList.add("is-collapsed"); 128 | this.graphControls.append(item); 129 | } 130 | 131 | async disconnectedCallback() { 132 | this.empty(); 133 | this.callbackUnregisterHandles.forEach((handle) => handle()); 134 | } 135 | } 136 | 137 | if (typeof customElements.get("graph-settings-view") === "undefined") { 138 | customElements.define("graph-settings-view", GraphSettingsView, { 139 | extends: "div", 140 | }); 141 | } 142 | -------------------------------------------------------------------------------- /src/views/settings/categories/DisplaySettingsView.ts: -------------------------------------------------------------------------------- 1 | import { DisplaySettings } from "../../../settings/categories/DisplaySettings"; 2 | import SimpleSliderSetting, { 3 | DEFAULT_SLIDER_STEP_OPTIONS, 4 | SliderOptions, 5 | } from "../../atomics/SimpleSliderSetting"; 6 | import State from "../../../util/State"; 7 | 8 | const DisplaySettingsView = ( 9 | displaySettings: State, 10 | containerEl: HTMLElement 11 | ) => { 12 | NodeSizeSetting(displaySettings, containerEl); 13 | LinkThicknessSetting(displaySettings, containerEl); 14 | ParticleSizeSetting(displaySettings, containerEl); 15 | ParticleCountSetting(displaySettings, containerEl); 16 | }; 17 | 18 | const NodeSizeSetting = ( 19 | displaySettings: State, 20 | containerEl: HTMLElement 21 | ) => { 22 | const options: SliderOptions = { 23 | name: "Node Size", 24 | value: displaySettings.value.nodeSize, 25 | stepOptions: DEFAULT_SLIDER_STEP_OPTIONS, 26 | }; 27 | return SimpleSliderSetting(containerEl, options, (value) => { 28 | displaySettings.value.nodeSize = value; 29 | }); 30 | }; 31 | 32 | const LinkThicknessSetting = ( 33 | displaySettings: State, 34 | containerEl: HTMLElement 35 | ) => { 36 | const options: SliderOptions = { 37 | name: "Link Thickness", 38 | value: displaySettings.value.linkThickness, 39 | stepOptions: DEFAULT_SLIDER_STEP_OPTIONS, 40 | }; 41 | return SimpleSliderSetting(containerEl, options, (value) => { 42 | displaySettings.value.linkThickness = value; 43 | }); 44 | }; 45 | 46 | const ParticleSizeSetting = ( 47 | displaySettings: State, 48 | containerEl: HTMLElement 49 | ) => { 50 | const options: SliderOptions = { 51 | name: "Particle Size", 52 | value: displaySettings.value.particleSize, 53 | stepOptions: DEFAULT_SLIDER_STEP_OPTIONS, 54 | }; 55 | return SimpleSliderSetting(containerEl, options, (value) => { 56 | displaySettings.value.particleSize = value; 57 | }); 58 | }; 59 | 60 | const ParticleCountSetting = ( 61 | displaySettings: State, 62 | containerEl: HTMLElement 63 | ) => { 64 | const options: SliderOptions = { 65 | name: "Particle Count", 66 | value: displaySettings.value.particleCount, 67 | stepOptions: DEFAULT_SLIDER_STEP_OPTIONS, 68 | }; 69 | return SimpleSliderSetting(containerEl, options, (value) => { 70 | displaySettings.value.particleCount = value; 71 | }); 72 | }; 73 | 74 | export default DisplaySettingsView; 75 | -------------------------------------------------------------------------------- /src/views/settings/categories/FilterSettingsView.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from "obsidian"; 2 | import { FilterSettings } from "src/settings/categories/FilterSettings"; 3 | import State from "src/util/State"; 4 | 5 | const FilterSettingsView = ( 6 | filterSettings: State, 7 | containerEl: HTMLElement 8 | ) => { 9 | new Setting(containerEl).setName("Show Orphans").addToggle((toggle) => { 10 | toggle 11 | .setValue(filterSettings.value.doShowOrphans || false) 12 | .onChange(async (value) => { 13 | filterSettings.value.doShowOrphans = value; 14 | }); 15 | }); 16 | new Setting(containerEl).setName("Show Attachments").addToggle((toggle) => { 17 | toggle 18 | .setValue(filterSettings.value.doShowAttachments || false) 19 | .onChange(async (value) => { 20 | filterSettings.value.doShowAttachments = value; 21 | }); 22 | }); 23 | }; 24 | 25 | export default FilterSettingsView; 26 | -------------------------------------------------------------------------------- /src/views/settings/categories/GroupSettingsView.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent, ExtraButtonComponent, TextComponent } from "obsidian"; 2 | import { 3 | GroupSettings, 4 | NodeGroup, 5 | } from "src/settings/categories/GroupSettings"; 6 | import ObsidianTheme from "src/util/ObsidianTheme"; 7 | import State, { StateChange } from "src/util/State"; 8 | import ColorPicker from "src/views/atomics/ColorPicker"; 9 | 10 | const GroupSettingsView = ( 11 | groupSettings: State, 12 | containerEl: HTMLElement, 13 | theme: ObsidianTheme 14 | ) => { 15 | NodeGroups(groupSettings, containerEl); 16 | AddNodeGroupButton(groupSettings, containerEl, theme); 17 | 18 | groupSettings.onChange((change: StateChange) => { 19 | if ( 20 | (change.currentPath === "groups" && change.type === "add") || 21 | change.type === "delete" 22 | ) { 23 | containerEl.empty(); 24 | NodeGroups(groupSettings, containerEl); 25 | AddNodeGroupButton(groupSettings, containerEl, theme); 26 | } 27 | }); 28 | }; 29 | 30 | const NodeGroups = ( 31 | groupSettings: State, 32 | containerEl: HTMLElement 33 | ) => { 34 | containerEl.querySelector(".node-group-container")?.remove(); 35 | const nodeGroupContainerEl = containerEl.createDiv({ 36 | cls: "graph-color-groups-container", 37 | }); 38 | groupSettings.value.groups.forEach((group, index) => { 39 | const groupState = groupSettings.createSubState( 40 | `value.groups.${index}`, 41 | NodeGroup 42 | ); 43 | GroupSettingItem(groupState, nodeGroupContainerEl, () => { 44 | groupSettings.value.groups.splice(index, 1); 45 | }); 46 | }); 47 | }; 48 | 49 | const AddNodeGroupButton = ( 50 | groupSettings: State, 51 | containerEl: HTMLElement, 52 | theme: ObsidianTheme 53 | ) => { 54 | containerEl.querySelector(".graph-color-button-container")?.remove(); 55 | 56 | const buttonContainer = containerEl.createDiv({ 57 | cls: "graph-color-button-container", 58 | }); 59 | new ButtonComponent(buttonContainer) 60 | .setClass("mod-cta") 61 | .setButtonText("Add Group") 62 | .onClick(() => { 63 | groupSettings.value.groups.push(new NodeGroup("", theme.textMuted)); 64 | containerEl.empty(); 65 | GroupSettingsView(groupSettings, containerEl, theme); 66 | }); 67 | }; 68 | const GroupSettingItem = ( 69 | group: State, 70 | containerEl: HTMLElement, 71 | onDelete: () => void 72 | ) => { 73 | const groupEl = containerEl.createDiv({ cls: "graph-color-group" }); 74 | 75 | new TextComponent(groupEl).setValue(group.value.query).onChange((value) => { 76 | group.value.query = value; 77 | }); 78 | 79 | ColorPicker(groupEl, group.value.color, (value) => { 80 | group.value.color = value; 81 | }); 82 | 83 | new ExtraButtonComponent(groupEl) 84 | .setIcon("cross") 85 | .setTooltip("Delete Group") 86 | .onClick(onDelete); 87 | }; 88 | 89 | export default GroupSettingsView; 90 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .graph-3d-view .tree-item.is-collapsed > .tree-item-children { 2 | display: none; 3 | visibility: hidden; 4 | } 5 | 6 | .graph-3d-view { 7 | padding: 0 !important; 8 | position: relative; 9 | overflow: hidden !important; 10 | } 11 | 12 | .graph-3d-view .graph-controls.is-collapsed > .graph-control-section { 13 | display: none; 14 | visibility: hidden; 15 | } 16 | .graph-3d-view .graph-controls:hover > .control-buttons { 17 | opacity: 0.5; 18 | } 19 | 20 | .graph-3d-view .graph-controls > .control-buttons:hover { 21 | opacity: 1; 22 | } 23 | 24 | .graph-3d-view .graph-controls > .control-buttons { 25 | float: right; 26 | margin-right: 0; 27 | opacity: 0; 28 | } 29 | 30 | .graph-3d-view .hidden { 31 | display: none; 32 | visibility: hidden; 33 | } 34 | 35 | .graph-3d-view .control-buttons { 36 | display: block; 37 | } 38 | 39 | .graph-3d-view .control-buttons > * { 40 | display: inline-block; 41 | margin: 0; 42 | } 43 | 44 | .graph-3d-view .graph-settings-view > .clickable-icon { 45 | position: absolute; 46 | top: 8px; 47 | right: 8px; 48 | } 49 | 50 | .graph-3d-view .node-label { 51 | color: var(--text-normal); 52 | } 53 | 54 | .graph-3d-view .scene-nav-info { 55 | display: none; 56 | visibility: hidden; 57 | } 58 | -------------------------------------------------------------------------------- /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 | "allowSyntheticDefaultImports": true 21 | }, 22 | "include": [ 23 | "**/*.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.3": "0.15.0", 3 | "1.0.4": "0.15.0" 4 | } 5 | --------------------------------------------------------------------------------