├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ ├── feature_request.md │ └── question.md ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── ci.yml │ ├── create_release.yml │ ├── dependabot_automerge.yml │ └── release_helper.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.cjs ├── .vscode └── settings.json ├── .yarn └── releases │ └── yarn-4.5.1.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── eslint.config.mjs ├── package.json ├── rollup.config.mjs ├── samples ├── bubbleclusters.html ├── component.html ├── default.html ├── default_esm.html └── euler.html ├── src ├── BubbleSetPath.ts ├── BubbleSetsPlugin.ts └── index.ts ├── tsconfig.c.json ├── tsconfig.json ├── typedoc.json ├── vitest.config.ts └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # These settings are for any web project 2 | 3 | # Handle line endings automatically for files detected as text 4 | # and leave all files detected as binary untouched. 5 | * text=auto eol=lf 6 | 7 | # 8 | # The above will handle all files NOT found below 9 | # 10 | 11 | # 12 | ## These files are text and should be normalized (Convert crlf => lf) 13 | # 14 | 15 | # source code 16 | *.php text 17 | *.css text 18 | *.sass text 19 | *.scss text 20 | *.less text 21 | *.styl text 22 | *.js text 23 | *.ts text 24 | *.coffee text 25 | *.json text 26 | *.htm text 27 | *.html text 28 | *.xml text 29 | *.txt text 30 | *.ini text 31 | *.inc text 32 | *.pl text 33 | *.rb text 34 | *.py text 35 | *.scm text 36 | *.sql text 37 | *.sh text eof=LF 38 | *.bat text 39 | 40 | # templates 41 | *.hbt text 42 | *.jade text 43 | *.haml text 44 | *.hbs text 45 | *.dot text 46 | *.tmpl text 47 | *.phtml text 48 | 49 | # server config 50 | .htaccess text 51 | 52 | # git config 53 | .gitattributes text 54 | .gitignore text 55 | 56 | # code analysis config 57 | .jshintrc text 58 | .jscsrc text 59 | .jshintignore text 60 | .csslintrc text 61 | 62 | # misc config 63 | *.yaml text 64 | *.yml text 65 | .editorconfig text 66 | 67 | # build config 68 | *.npmignore text 69 | *.bowerrc text 70 | Dockerfile text eof=LF 71 | 72 | # Heroku 73 | Procfile text 74 | .slugignore text 75 | 76 | # Documentation 77 | *.md text 78 | LICENSE text 79 | AUTHORS text 80 | 81 | 82 | # 83 | ## These files are binary and should be left untouched 84 | # 85 | 86 | # (binary is a macro for -text -diff) 87 | *.png binary 88 | *.jpg binary 89 | *.jpeg binary 90 | *.gif binary 91 | *.ico binary 92 | *.mov binary 93 | *.mp4 binary 94 | *.mp3 binary 95 | *.flv binary 96 | *.fla binary 97 | *.swf binary 98 | *.gz binary 99 | *.zip binary 100 | *.7z binary 101 | *.ttf binary 102 | *.pyc binary 103 | *.pdf binary 104 | 105 | # Source files 106 | # ============ 107 | *.pxd text 108 | *.py text 109 | *.py3 text 110 | *.pyw text 111 | *.pyx text 112 | *.sh text eol=lf 113 | *.json text 114 | 115 | # Binary files 116 | # ============ 117 | *.db binary 118 | *.p binary 119 | *.pkl binary 120 | *.pyc binary 121 | *.pyd binary 122 | *.pyo binary 123 | 124 | # Note: .db, .p, and .pkl files are associated 125 | # with the python modules ``pickle``, ``dbm.*``, 126 | # ``shelve``, ``marshal``, ``anydbm``, & ``bsddb`` 127 | # (among others). 128 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [sgratzl] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | about: If something isn't working as expected 🤔. 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | When I... 12 | 13 | **To Reproduce** 14 | 15 | 17 | 18 | 1. 19 | 20 | **Expected behavior** 21 | 22 | 23 | 24 | **Screenshots** 25 | 26 | 27 | 28 | **Context** 29 | 30 | - Version: 31 | - Browser: 32 | 33 | **Additional context** 34 | 35 | 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | # contact_links: 3 | # - name: Samuel Gratzl 4 | # url: https://www.sgratzl.com 5 | # about: Please ask and answer questions here. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature Request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | It would be great if ... 12 | 13 | **User story** 14 | 15 | 16 | 17 | **Additional context** 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🤗 Question 3 | about: ask question about the library (usage, features,...) 4 | title: '' 5 | labels: 'question' 6 | assignees: '' 7 | --- 8 | 9 | 13 | 14 | I'm having the following question... 15 | 16 | **Screenshots / Sketches** 17 | 18 | 19 | 20 | **Context** 21 | 22 | - Version: 23 | - Browser: 24 | 25 | **Additional context** 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: 'github-actions' 5 | directory: '/' 6 | schedule: 7 | interval: 'monthly' 8 | target-branch: 'dev' 9 | labels: 10 | - 'dependencies' 11 | - 'chore' 12 | - package-ecosystem: 'npm' 13 | directory: '/' 14 | schedule: 15 | interval: 'monthly' 16 | target-branch: 'dev' 17 | labels: 18 | - 'dependencies' 19 | - 'chore' 20 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'enhancement' 7 | - 'feature' 8 | - title: '🐛 Bugs Fixes' 9 | labels: 10 | - 'bug' 11 | - title: 'Documentation' 12 | labels: 13 | - 'documentation' 14 | - title: '🧰 Development' 15 | labels: 16 | - 'chore' 17 | change-template: '- #$NUMBER $TITLE' 18 | change-title-escapes: '\<*_&`#@' 19 | template: | 20 | $CHANGES 21 | 22 | Thanks to $CONTRIBUTORS 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | if: github.actor != 'dependabot[bot]' 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | - run: npm i -g yarn 17 | - run: yarn config set checksumBehavior ignore 18 | - name: Cache Node.js modules 19 | uses: actions/cache@v4 20 | with: 21 | path: | 22 | ./.yarn/cache 23 | ./.yarn/unplugged 24 | key: ${{ runner.os }}-yarn2-v4-${{ hashFiles('**/yarn.lock') }} 25 | restore-keys: | 26 | ${{ runner.os }}-yarn2-v4 27 | - run: yarn install 28 | - run: yarn build 29 | - run: yarn lint 30 | - run: yarn test:coverage 31 | # - run: yarn docs 32 | # - name: Deploy 33 | # if: github.ref == 'refs/heads/master' && github.event_name == 'push' 34 | # uses: peaceiris/actions-gh-pages@v3 35 | # with: 36 | # github_token: ${{ secrets.GITHUB_TOKEN }} 37 | # publish_dir: ./docs 38 | # enable_jekyll: false 39 | -------------------------------------------------------------------------------- /.github/workflows/create_release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | versionName: 6 | description: 'Semantic Version Number (i.e., 5.5.0 or patch, minor, major, prepatch, preminor, premajor, prerelease)' 7 | required: true 8 | default: patch 9 | preid: 10 | description: 'Pre Release Identifier (i.e., alpha, beta)' 11 | required: true 12 | default: alpha 13 | jobs: 14 | create_release: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out code 18 | uses: actions/checkout@v4 19 | with: 20 | ref: main 21 | ssh-key: ${{ secrets.PRIVATE_SSH_KEY }} 22 | - name: Reset main branch 23 | run: | 24 | git fetch origin dev:dev 25 | git reset --hard origin/dev 26 | - name: Change version number 27 | id: version 28 | run: | 29 | echo "next_tag=$(npm version --no-git-tag-version ${{ github.event.inputs.versionName }} --preid ${{ github.event.inputs.preid }})" >> $GITHUB_OUTPUT 30 | - name: Create pull request into main 31 | uses: peter-evans/create-pull-request@v7 32 | with: 33 | branch: release/${{ steps.version.outputs.next_tag }} 34 | commit-message: 'chore: release ${{ steps.version.outputs.next_tag }}' 35 | base: main 36 | title: Release ${{ steps.version.outputs.next_tag }} 37 | labels: chore 38 | assignees: sgratzl 39 | body: | 40 | Releasing ${{ steps.version.outputs.next_tag }}. 41 | -------------------------------------------------------------------------------- /.github/workflows/dependabot_automerge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: 3 | pull_request: 4 | branches: 5 | - 'dev' # only into dev 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot: 13 | runs-on: ubuntu-latest 14 | if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'sgratzl/cytoscape.js-layers' 15 | steps: 16 | - name: Dependabot metadata 17 | id: metadata 18 | uses: dependabot/fetch-metadata@v2 19 | with: 20 | github-token: '${{ secrets.GITHUB_TOKEN }}' 21 | - name: Enable auto-merge for Dependabot PRs 22 | # patch only 23 | if: steps.metadata.outputs.update-type == 'version-update:semver-patch' 24 | run: gh pr merge --auto --merge "$PR_URL" 25 | env: 26 | PR_URL: ${{github.event.pull_request.html_url}} 27 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 28 | -------------------------------------------------------------------------------- /.github/workflows/release_helper.yml: -------------------------------------------------------------------------------- 1 | name: Release Helper 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | correct_repository: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: fail on fork 12 | if: github.repository_owner != 'upsetjs' 13 | run: exit 1 14 | 15 | create_release: 16 | needs: correct_repository 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Check out code 20 | uses: actions/checkout@v4 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | - name: Extract version 25 | id: extract_version 26 | run: | 27 | node -pe "'version=' + require('./package.json').version" >> $GITHUB_OUTPUT 28 | node -pe "'npm_tag=' + (require('./package.json').version.includes('-') ? 'next' : 'latest')" >> $GITHUB_OUTPUT 29 | - name: Print version 30 | run: | 31 | echo "releasing ${{ steps.extract_version.outputs.version }} with tag ${{ steps.extract_version.outputs.npm_tag }}" 32 | - name: Create Release 33 | id: create_release 34 | uses: release-drafter/release-drafter@v6 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | with: 38 | name: v${{ steps.extract_version.outputs.version }} 39 | tag: v${{ steps.extract_version.outputs.version }} 40 | version: ${{ steps.extract_version.outputs.version }} 41 | prerelease: ${{ needs.create_release.outputs.tag_name == 'next' }} 42 | publish: true 43 | outputs: 44 | version: ${{ steps.extract_version.outputs.version }} 45 | npm_tag: ${{ steps.extract_version.outputs.npm_tag }} 46 | upload_url: ${{ steps.create_release.outputs.upload_url }} 47 | tag_name: ${{ steps.create_release.outputs.tag_name }} 48 | 49 | build_assets: 50 | needs: create_release 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Check out code 54 | uses: actions/checkout@v4 55 | - uses: actions/setup-node@v4 56 | with: 57 | node-version: 20 58 | - run: npm i -g yarn 59 | - run: yarn config set checksumBehavior ignore 60 | - name: Cache Node.js modules 61 | uses: actions/cache@v4 62 | with: 63 | path: | 64 | ./.yarn/cache 65 | ./.yarn/unplugged 66 | key: ${{ runner.os }}-yarn2-v4-${{ hashFiles('**/yarn.lock') }} 67 | restore-keys: | 68 | ${{ runner.os }}-yarn2-v4 69 | - run: yarn install 70 | - run: yarn build 71 | - run: yarn pack 72 | - name: Upload Release Asset 73 | uses: AButler/upload-release-assets@v3.0 74 | with: 75 | files: 'package.tgz' 76 | repo-token: ${{ secrets.GITHUB_TOKEN }} 77 | release-tag: ${{ needs.create_release.outputs.tag_name }} 78 | - name: Pack Publish 79 | run: | 80 | yarn config set npmAuthToken "${{ secrets.NPM_TOKEN }}" 81 | yarn pack 82 | yarn npm publish --tag "${{ needs.create_release.outputs.npm_tag }}" 83 | 84 | sync_dev: 85 | needs: correct_repository 86 | runs-on: ubuntu-latest 87 | steps: 88 | - name: Check out code 89 | uses: actions/checkout@v4 90 | with: 91 | ref: dev 92 | ssh-key: ${{ secrets.PRIVATE_SSH_KEY }} 93 | - name: Reset dev branch 94 | run: | 95 | git fetch origin main:main 96 | git merge main 97 | git push 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | /coverage 7 | /node_modules 8 | .npm 9 | .yarn/* 10 | !.yarn/patches 11 | !.yarn/releases 12 | !.yarn/plugins 13 | !.yarn/versions 14 | .pnp.* 15 | 16 | # Build files 17 | /.tmp 18 | /build 19 | 20 | *.tgz 21 | /.vscode/extensions.json 22 | /docs 23 | samples/*.map 24 | samples/*.js 25 | *.tsbuildinfo 26 | .eslintcache 27 | /docs -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.pnp.* 2 | /.yarnrc.yml 3 | /.yarn 4 | /build 5 | /docs 6 | /coverage 7 | /.gitattributes 8 | /.gitignore 9 | /.prettierignore 10 | /LICENSE 11 | /yarn.lock 12 | /.vscode 13 | *.tsbuildinfo 14 | *.map 15 | samples/*.js 16 | src/**/*.js -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | semi: true, 4 | singleQuote: true, 5 | trailingComma: 'es5', 6 | }; 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.formatOnType": true, 4 | "[javascript]": { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode" 6 | }, 7 | "[typescript]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "[json]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "[yaml]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "eslint.nodePath": ".yarn/sdks", 17 | "prettier.prettierPath": ".yarn/sdks/prettier/index.cjs", 18 | "files.eol": "\n", 19 | "editor.detectIndentation": false, 20 | "editor.tabSize": 2, 21 | "typescript.tsdk": ".yarn/sdks/typescript/lib", 22 | "typescript.enablePromptUseWorkspaceTsdk": true, 23 | "search.exclude": { 24 | "**/.yarn": true, 25 | "**/.pnp.*": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-4.5.1.cjs 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2022 Samuel Gratzl 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 | # Cytoscape.js BubbleSets Plugin 2 | 3 | [![NPM Package][npm-image]][npm-url] [![Github Actions][github-actions-image]][github-actions-url] [![Cytoscape Plugin][cytoscape-image]][cytoscape-url] 4 | 5 | A [Cytoscape.js](https://js.cytoscape.org) plugin for rendering [Bubblesets](https://github.com/upsetjs/bubblesets-js). 6 | 7 | ![Euler Example](https://user-images.githubusercontent.com/4129778/83965199-249aef00-a8b2-11ea-866e-4b0207c7b446.png) 8 | 9 | ## Install 10 | 11 | ```sh 12 | npm install cytoscape cytoscape-layers cytoscape-bubblesets 13 | ``` 14 | 15 | ## Usage 16 | 17 | see [Samples](./samples) on Github 18 | 19 | or at this [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/RwQdBLY) 20 | 21 | ```js 22 | import cytoscape from 'cytoscape'; 23 | import BubbleSets from 'cytoscape-bubblesets'; 24 | cytoscape.use(BubbleSets); 25 | 26 | const cy = cytoscape({ 27 | container: document.getElementById('app'), 28 | elements: [ 29 | { data: { id: 'a' } }, 30 | { data: { id: 'b' } }, 31 | { 32 | data: { 33 | id: 'ab', 34 | source: 'a', 35 | target: 'b', 36 | }, 37 | }, 38 | ], 39 | }); 40 | cy.ready(() => { 41 | const bb = cy.bubbleSets(); 42 | bb.addPath(cy.nodes(), cy.edges(), null); 43 | }); 44 | ``` 45 | 46 | ![image](https://user-images.githubusercontent.com/4129778/83965802-8cebcf80-a8b6-11ea-9481-1744521fe8a1.png) 47 | 48 | Alternative without registration 49 | 50 | ```js 51 | import cytoscape from 'cytoscape'; 52 | import { BubbleSetsPlugin } from 'cytoscape-bubblesets'; 53 | 54 | const cy = cytoscape({ 55 | container: document.getElementById('app'), 56 | elements: [ 57 | { data: { id: 'a' } }, 58 | { data: { id: 'b' } }, 59 | { 60 | data: { 61 | id: 'ab', 62 | source: 'a', 63 | target: 'b', 64 | }, 65 | }, 66 | ], 67 | }); 68 | cy.ready(() => { 69 | const bb = new BubbleSetsPlugin(cy); 70 | bb.addPath(cy.nodes(), cy.edges(), null); 71 | }); 72 | ``` 73 | 74 | ## API 75 | 76 | - `addPath(nodes: NodeCollection, edges?: EdgeCollection | null, avoidNodes?: NodeCollection | null, options?: IBubbleSetPathOptions): BubbleSetPath` 77 | 78 | creates a new `BubbleSetPath` instance. The `nodes` is a node collection that should be linked. `edges` an edge collection to include edges. `avoidNodes` is an optional node collection of nodes that should be avoided when generating the outline and any virtual edge between the nodes. 79 | 80 | - `removePath(path: BubbleSetPath)` 81 | 82 | removes a path again 83 | 84 | - `getPaths(): readonly BubbleSetPath[]` 85 | 86 | returns the list of active paths 87 | 88 | ## Development Environment 89 | 90 | ```sh 91 | npm i -g yarn 92 | yarn set version latest 93 | cat .yarnrc_patch.yml >> .yarnrc.yml 94 | yarn 95 | yarn pnpify --sdk vscode 96 | ``` 97 | 98 | ### Common commands 99 | 100 | ```sh 101 | yarn compile 102 | yarn test 103 | yarn lint 104 | yarn fix 105 | yarn build 106 | yarn docs 107 | yarn release 108 | yarn release:pre 109 | ``` 110 | 111 | [npm-image]: https://badge.fury.io/js/cytoscape-bubblesets.svg 112 | [npm-url]: https://npmjs.org/package/cytoscape-bubblesets 113 | [github-actions-image]: https://github.com/upsetjs/cytoscape.js-bubblesets/workflows/ci/badge.svg 114 | [github-actions-url]: https://github.com/upsetjs/cytoscape.js-bubblesets/actions 115 | [cytoscape-image]: https://img.shields.io/badge/Cytoscape-plugin-yellow 116 | [cytoscape-url]: https://js.cytoscape.org/#extensions/ui-extensions 117 | [codepen]: https://img.shields.io/badge/CodePen-open-blue?logo=codepen 118 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | import prettier from 'eslint-plugin-prettier'; 6 | 7 | export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended, { 8 | plugins: { prettier }, 9 | rules: { 10 | '@typescript-eslint/no-explicit-any': 'off', 11 | 'max-classes-per-file': 'off', 12 | 'no-underscore-dangle': 'off', 13 | 'import/extensions': 'off', 14 | }, 15 | }); 16 | 17 | // import path from "node:path"; 18 | // import { fileURLToPath } from "node:url"; 19 | // import js from "@eslint/js"; 20 | // import { FlatCompat } from "@eslint/eslintrc"; 21 | 22 | // const __filename = fileURLToPath(import.meta.url); 23 | // const __dirname = path.dirname(__filename); 24 | // const compat = new FlatCompat({ 25 | // baseDirectory: __dirname, 26 | // recommendedConfig: js.configs.recommended, 27 | // allConfig: js.configs.all 28 | // }); 29 | 30 | // export default [...fixupConfigRules(compat.extends( 31 | // "airbnb-typescript", 32 | // "react-app", 33 | // "plugin:prettier/recommended", 34 | // "prettier", 35 | // )), { 36 | // plugins: { 37 | // prettier: fixupPluginRules(prettier), 38 | // }, 39 | 40 | // languageOptions: { 41 | // ecmaVersion: 5, 42 | // sourceType: "script", 43 | 44 | // parserOptions: { 45 | // project: "./tsconfig.eslint.json", 46 | // }, 47 | // }, 48 | 49 | // settings: { 50 | // react: { 51 | // version: "99.99.99", 52 | // }, 53 | // }, 54 | 55 | // rules: { 56 | // "@typescript-eslint/no-explicit-any": "off", 57 | // "max-classes-per-file": "off", 58 | // "no-underscore-dangle": "off", 59 | // "import/extensions": "off", 60 | // }, 61 | // }]; 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cytoscape-bubblesets", 3 | "description": "Cytoscape.js plugin for rendering bubblesets", 4 | "version": "4.0.0", 5 | "author": { 6 | "name": "Samuel Gratzl", 7 | "email": "sam@sgratzl.com", 8 | "url": "https://www.sgratzl.com" 9 | }, 10 | "license": "MIT", 11 | "homepage": "https://github.com/upsetjs/cytoscape.js-bubblesets", 12 | "bugs": { 13 | "url": "https://github.com/upsetjs/cytoscape.js-bubblesets/issues" 14 | }, 15 | "keywords": [ 16 | "cytoscape", 17 | "bubblesets" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/upsetjs/cytoscape.js-bubblesets.git" 22 | }, 23 | "global": "CytoscapeBubbleSets", 24 | "dependencies": { 25 | "@types/cytoscape": "^3.21.8", 26 | "@types/lodash.throttle": "^4.1.9", 27 | "bubblesets-js": "^3.0.0", 28 | "lodash.throttle": "^4.1.1" 29 | }, 30 | "peerDependencies": { 31 | "cytoscape": "^3.23.0", 32 | "cytoscape-layers": "^3.0.0" 33 | }, 34 | "browserslist": [ 35 | "Firefox ESR", 36 | "last 2 Chrome versions", 37 | "last 2 Firefox versions" 38 | ], 39 | "type": "module", 40 | "main": "build/index.js", 41 | "module": "build/index.js", 42 | "require": "build/index.cjs", 43 | "umd": "build/index.umd.js", 44 | "unpkg": "build/index.umd.min.js", 45 | "jsdelivr": "build/index.umd.min.js", 46 | "types": "build/index.d.ts", 47 | "exports": { 48 | ".": { 49 | "import": "./build/index.js", 50 | "require": "./build/index.cjs", 51 | "scripts": "./build/index.umd.min.js", 52 | "types": "./build/index.d.ts" 53 | } 54 | }, 55 | "sideEffects": false, 56 | "files": [ 57 | "build", 58 | "src/**/*.ts", 59 | "src/**/*.tsx" 60 | ], 61 | "devDependencies": { 62 | "@babel/core": "^7.26.0", 63 | "@babel/preset-env": "^7.26.0", 64 | "@eslint/js": "~9.15.0", 65 | "@rollup/plugin-babel": "^6.0.4", 66 | "@rollup/plugin-commonjs": "^28.0.1", 67 | "@rollup/plugin-node-resolve": "^15.3.0", 68 | "@rollup/plugin-replace": "^6.0.1", 69 | "@rollup/plugin-typescript": "^12.1.1", 70 | "@vitest/coverage-v8": "^2.1.5", 71 | "@yarnpkg/sdks": "^3.2.0", 72 | "cytoscape": "^3.30.3", 73 | "cytoscape-layers": "^3.0.0", 74 | "eslint": "~9.14.0", 75 | "eslint-plugin-prettier": "^5.2.1", 76 | "jsdom": "^25.0.1", 77 | "prettier": "^3.3.3", 78 | "rimraf": "^6.0.1", 79 | "rollup": "^4.27.2", 80 | "rollup-plugin-dts": "^6.1.1", 81 | "rollup-plugin-terser": "^7.0.2", 82 | "tslib": "^2.8.1", 83 | "typedoc": "^0.26.11", 84 | "typescript": "^5.6.3", 85 | "typescript-eslint": "^8.14.0", 86 | "vite": "^5.4.11", 87 | "vitest": "^2.1.5" 88 | }, 89 | "scripts": { 90 | "clean": "rimraf --glob build node_modules \"*.tgz\" \"*.tsbuildinfo\"", 91 | "compile": "tsc -b tsconfig.c.json", 92 | "start": "yarn run watch", 93 | "watch": "rollup -c -w", 94 | "build": "rollup -c", 95 | "test": "vitest --passWithNoTests", 96 | "test:watch": "yarn run test --watch", 97 | "test:coverage": "yarn run test --coverage", 98 | "lint": "yarn run eslint && yarn run prettier", 99 | "fix": "yarn run eslint:fix && yarn run prettier:write", 100 | "prettier:write": "prettier \"*\" \"*/**\" --write", 101 | "prettier": "prettier \"*\" \"*/**\" --check", 102 | "eslint": "eslint src --cache", 103 | "eslint:fix": "yarn run eslint --fix", 104 | "docs": "typedoc --options typedoc.json", 105 | "prepare": "yarn run build" 106 | }, 107 | "packageManager": "yarn@4.5.1" 108 | } 109 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import dts from 'rollup-plugin-dts'; 4 | import typescript from '@rollup/plugin-typescript'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | import replace from '@rollup/plugin-replace'; 7 | import babel from '@rollup/plugin-babel'; 8 | 9 | import fs from 'fs'; 10 | 11 | const pkg = JSON.parse(fs.readFileSync('./package.json')); 12 | 13 | function resolveYear() { 14 | // Extract copyrights from the LICENSE. 15 | const license = fs.readFileSync('./LICENSE', 'utf-8').toString(); 16 | const matches = Array.from(license.matchAll(/\(c\) (\d+-\d+)/gm)); 17 | if (!matches || matches.length === 0) { 18 | return 2021; 19 | } 20 | return matches[matches.length - 1][1]; 21 | } 22 | const year = resolveYear(); 23 | 24 | const banner = `/** 25 | * ${pkg.name} 26 | * ${pkg.homepage} 27 | * 28 | * Copyright (c) ${year} ${pkg.author.name} <${pkg.author.email}> 29 | */ 30 | `; 31 | 32 | /** 33 | * defines which formats (umd, esm, cjs, types) should be built when watching 34 | */ 35 | const watchOnly = ['umd']; 36 | 37 | const isDependency = (v) => Object.keys(pkg.dependencies || {}).some((e) => e === v || v.startsWith(e + '/')); 38 | const isPeerDependency = (v) => Object.keys(pkg.peerDependencies || {}).some((e) => e === v || v.startsWith(e + '/')); 39 | 40 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 41 | export default function Config(options) { 42 | const buildFormat = (format) => !options.watch || watchOnly.includes(format); 43 | 44 | const base = { 45 | input: './src/index.ts', 46 | output: { 47 | sourcemap: true, 48 | banner, 49 | exports: 'named', 50 | globals: { 51 | cytoscape: 'cytoscape', 52 | 'cytoscape-layers': 'CytoscapeLayers', 53 | crypto: 'NodeCrypto', 54 | }, 55 | }, 56 | external: (v) => isDependency(v) || isPeerDependency(v), 57 | plugins: [ 58 | typescript(), 59 | resolve(), 60 | commonjs(), 61 | replace({ 62 | preventAssignment: true, 63 | values: { 64 | // eslint-disable-next-line no-undef 65 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) || 'production', 66 | __VERSION__: JSON.stringify(pkg.version), 67 | }, 68 | }), 69 | ], 70 | }; 71 | return [ 72 | (buildFormat('esm') || buildFormat('cjs')) && { 73 | ...base, 74 | output: [ 75 | buildFormat('esm') && { 76 | ...base.output, 77 | file: pkg.module, 78 | format: 'esm', 79 | }, 80 | buildFormat('cjs') && { 81 | ...base.output, 82 | file: pkg.require, 83 | format: 'cjs', 84 | }, 85 | ].filter(Boolean), 86 | }, 87 | ((buildFormat('umd') && pkg.umd) || (buildFormat('umd-min') && pkg.unpkg)) && { 88 | ...base, 89 | input: fs.existsSync(base.input.replace('.ts', '.umd.ts')) ? base.input.replace('.ts', '.umd.ts') : base.input, 90 | output: [ 91 | buildFormat('umd') && 92 | pkg.umd && { 93 | ...base.output, 94 | file: pkg.umd, 95 | format: 'umd', 96 | name: pkg.global, 97 | }, 98 | buildFormat('umd-min') && 99 | pkg.unpkg && { 100 | ...base.output, 101 | file: pkg.unpkg, 102 | format: 'umd', 103 | name: pkg.global, 104 | plugins: [terser()], 105 | }, 106 | ].filter(Boolean), 107 | external: (v) => isPeerDependency(v), 108 | plugins: [...base.plugins, babel({ presets: ['@babel/env'], babelHelpers: 'bundled' })], 109 | }, 110 | buildFormat('types') && { 111 | ...base, 112 | output: { 113 | ...base.output, 114 | file: pkg.types, 115 | format: 'es', 116 | }, 117 | plugins: [ 118 | dts({ 119 | compilerOptions: { 120 | removeComments: false, 121 | }, 122 | respectExternal: true, 123 | }), 124 | ], 125 | }, 126 | ].filter(Boolean); 127 | } 128 | -------------------------------------------------------------------------------- /samples/bubbleclusters.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sample 5 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /samples/component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sample 5 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /samples/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sample 5 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /samples/default_esm.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ESM Sample 5 | 12 | 13 | 14 |
15 | 16 | 25 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /samples/euler.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sample 5 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /src/BubbleSetPath.ts: -------------------------------------------------------------------------------- 1 | import type cy from 'cytoscape'; 2 | import { 3 | type IOutlineOptions, 4 | Area, 5 | createLineInfluenceArea, 6 | createGenericInfluenceArea, 7 | createRectangleInfluenceArea, 8 | Rectangle, 9 | Circle, 10 | calculatePotentialOutline, 11 | type IRectangle, 12 | type IPotentialOptions, 13 | defaultOptions, 14 | calculateVirtualEdges, 15 | type IRoutingOptions, 16 | type ILine, 17 | Line, 18 | } from 'bubblesets-js'; 19 | import throttle from 'lodash.throttle'; 20 | 21 | export interface IBubbleSetPathOptions extends IOutlineOptions, IPotentialOptions, ISVGPathStyle, IRoutingOptions { 22 | throttle?: number; 23 | interactive?: boolean; 24 | 25 | includeLabels?: boolean; 26 | includeMainLabels?: boolean; 27 | includeOverlays?: boolean; 28 | includeSourceLabels?: boolean; 29 | includeTargetLabels?: boolean; 30 | } 31 | 32 | export interface ISVGPathStyle { 33 | style?: Partial; 34 | className?: string; 35 | } 36 | 37 | interface IBubbleSetNodeData { 38 | area?: Area; 39 | isCircle: boolean; 40 | shape: Circle | Rectangle; 41 | } 42 | interface IBubbleSetEdgeData { 43 | lines: Line[]; 44 | areas: Area[]; 45 | } 46 | 47 | function round2(v: number) { 48 | return Math.round(v * 100) / 100; 49 | } 50 | 51 | const SCRATCH_KEY = 'bubbleSets'; 52 | const circularBase = ['ellipse', 'diamond', 'diamond', 'pentagon', 'diamond', 'hexagon', 'heptagon', 'octagon', 'star']; 53 | const circular = new Set(circularBase.concat(circularBase.map((v) => `round-${v}`))); 54 | 55 | function isCircleShape(shape: string) { 56 | return circular.has(shape); 57 | } 58 | 59 | function toNodeKey(data: IBubbleSetNodeData) { 60 | return `${round2(data.shape.width)}x${round2(data.shape.height)}x${data.isCircle}`; 61 | } 62 | function toEdgeKey(line: ILine) { 63 | return `${round2(line.x1)}x${round2(line.y1)}x${round2(line.x2)}x${round2(line.y2)}`; 64 | } 65 | 66 | function linesEquals(a: ILine[], b: ILine[]) { 67 | return a.length === b.length && a.every((ai, i) => toEdgeKey(ai) === toEdgeKey(b[i])); 68 | } 69 | 70 | function createShape(isCircle: boolean, bb: cy.BoundingBox12 & cy.BoundingBoxWH) { 71 | return isCircle 72 | ? new Circle(bb.x1 + bb.w / 2, bb.y1 + bb.h / 2, Math.max(bb.w, bb.h) / 2) 73 | : new Rectangle(bb.x1, bb.y1, bb.w, bb.h); 74 | } 75 | 76 | export default class BubbleSetPath { 77 | #activeArea: IRectangle = { x: 0, y: 0, width: 0, height: 0 }; 78 | 79 | #potentialArea: Area = new Area(4, 0, 0, 0, 0, 0, 0); 80 | 81 | readonly #options: Required; 82 | 83 | readonly #virtualEdgeAreas = new Map(); 84 | 85 | readonly #throttledUpdate: () => void; 86 | 87 | readonly #adder: (e: cy.EventObject) => void; 88 | 89 | readonly #remover: (e: cy.EventObject) => void; 90 | 91 | readonly #adapter: { remove(path: BubbleSetPath): boolean }; 92 | 93 | constructor( 94 | adapter: { remove(path: BubbleSetPath): boolean }, 95 | public readonly node: SVGPathElement, 96 | public readonly nodes: cy.NodeCollection, 97 | public readonly edges: cy.EdgeCollection, 98 | public readonly avoidNodes: cy.NodeCollection, 99 | options: IBubbleSetPathOptions = {} 100 | ) { 101 | this.#adapter = adapter; 102 | this.#options = { 103 | ...defaultOptions, 104 | style: { 105 | stroke: 'black', 106 | fill: 'black', 107 | fillOpacity: '0.25', 108 | }, 109 | className: '', 110 | throttle: 100, 111 | virtualEdges: false, 112 | interactive: false, 113 | includeLabels: false, 114 | includeMainLabels: false, 115 | includeOverlays: false, 116 | includeSourceLabels: false, 117 | includeTargetLabels: false, 118 | ...options, 119 | }; 120 | 121 | Object.assign(this.node.style, this.#options.style); 122 | if (this.#options.className) { 123 | this.node.classList.add(this.#options.className); 124 | } 125 | 126 | if (this.#options.interactive) { 127 | this.node.addEventListener('dblclick', () => { 128 | this.nodes.select(); 129 | }); 130 | } 131 | 132 | this.#throttledUpdate = throttle(() => { 133 | this.update(); 134 | }, this.#options.throttle); 135 | this.#adder = (e) => { 136 | e.target.on('add', this.#adder); 137 | e.target.on('remove', this.#remover); 138 | this.#throttledUpdate(); 139 | }; 140 | this.#remover = (e) => { 141 | e.target.off('add', undefined, this.#adder); 142 | e.target.off('remove', undefined, this.#remover); 143 | this.#throttledUpdate(); 144 | }; 145 | 146 | nodes.on('position', this.#throttledUpdate); 147 | nodes.on('add', this.#adder); 148 | nodes.on('remove', this.#remover); 149 | avoidNodes.on('position', this.#throttledUpdate); 150 | avoidNodes.on('add', this.#adder); 151 | avoidNodes.on('remove', this.#remover); 152 | edges.on('move position', this.#throttledUpdate); 153 | edges.on('add', this.#adder); 154 | edges.on('remove', this.#remover); 155 | } 156 | 157 | update = (forceUpdate = false): void => { 158 | const bb = this.nodes.union(this.edges).boundingBox(this.#options); 159 | let potentialAreaDirty = false; 160 | const padding = Math.max(this.#options.edgeR1, this.#options.nodeR1) + this.#options.morphBuffer; 161 | const nextPotentialBB: IRectangle = { 162 | x: bb.x1 - padding, 163 | y: bb.y1 - padding, 164 | width: bb.w + padding * 2, 165 | height: bb.h + padding * 2, 166 | }; 167 | if (forceUpdate || this.#activeArea.x !== nextPotentialBB.x || this.#activeArea.y !== nextPotentialBB.y) { 168 | potentialAreaDirty = true; 169 | this.#potentialArea = Area.fromPixelRegion(nextPotentialBB, this.#options.pixelGroup); 170 | } else if (this.#activeArea.width !== nextPotentialBB.width || this.#activeArea.height !== nextPotentialBB.height) { 171 | // but not dirty 172 | this.#potentialArea = Area.fromPixelRegion(nextPotentialBB, this.#options.pixelGroup); 173 | } 174 | this.#activeArea = nextPotentialBB; 175 | const potentialArea = this.#potentialArea; 176 | 177 | const cache = new Map(); 178 | 179 | if (!potentialAreaDirty) { 180 | this.nodes.forEach((n) => { 181 | const data = (n.scratch(SCRATCH_KEY) ?? null) as IBubbleSetNodeData | null; 182 | if (data && data.area) { 183 | cache.set(toNodeKey(data), data.area); 184 | } 185 | }); 186 | } 187 | 188 | let updateEdges = false; 189 | const updateNodeData = (n: cy.NodeSingular) => { 190 | const nodeBB = n.boundingBox(this.#options); 191 | let data = (n.scratch(SCRATCH_KEY) ?? null) as IBubbleSetNodeData | null; 192 | const isCircle = isCircleShape(n.style('shape')); 193 | if ( 194 | !data || 195 | potentialAreaDirty || 196 | !data.area || 197 | data.isCircle !== isCircle || 198 | data.shape.width !== nodeBB.w || 199 | data.shape.height !== nodeBB.h 200 | ) { 201 | // full recreate 202 | updateEdges = true; 203 | data = { 204 | isCircle, 205 | shape: createShape(isCircle, nodeBB), 206 | }; 207 | const key = toNodeKey(data); 208 | const cached = cache.get(key); 209 | if (cached != null) { 210 | data.area = this.#potentialArea.copy(cached, { 211 | x: nodeBB.x1 - this.#options.nodeR1, 212 | y: nodeBB.y1 - this.#options.nodeR1, 213 | }); 214 | } else { 215 | data.area = data.isCircle 216 | ? createGenericInfluenceArea(data.shape, potentialArea, this.#options.nodeR1) 217 | : createRectangleInfluenceArea(data.shape, potentialArea, this.#options.nodeR1); 218 | cache.set(key, data.area); 219 | } 220 | n.scratch(SCRATCH_KEY, data); 221 | } else if (data.shape.x !== nodeBB.x1 || data.shape.y !== nodeBB.y1) { 222 | updateEdges = true; 223 | data.shape = createShape(isCircle, nodeBB); 224 | data.area = this.#potentialArea.copy(data.area, { 225 | x: nodeBB.x1 - this.#options.nodeR1, 226 | y: nodeBB.y1 - this.#options.nodeR1, 227 | }); 228 | } 229 | 230 | return data; 231 | }; 232 | 233 | const members = this.nodes.map(updateNodeData); 234 | const nonMembers = this.avoidNodes.map(updateNodeData); 235 | 236 | const edgeCache = new Map(); 237 | 238 | if (!potentialAreaDirty) { 239 | this.#virtualEdgeAreas.forEach((value, key) => edgeCache.set(key, value)); 240 | this.edges.forEach((n: cy.EdgeSingular) => { 241 | const data = (n.scratch(SCRATCH_KEY) ?? null) as IBubbleSetEdgeData | null; 242 | if (data && data.lines) { 243 | data.lines.forEach((line, i) => { 244 | const area = data.areas[i]; 245 | if (area) { 246 | cache.set(toEdgeKey(line), area); 247 | } 248 | }); 249 | } 250 | }); 251 | } 252 | const updateEdgeArea = (line: ILine) => { 253 | const key = toEdgeKey(line); 254 | const cached = edgeCache.get(key); 255 | if (cached != null) { 256 | return cached; 257 | } 258 | const r = createLineInfluenceArea(line, this.#potentialArea, this.#options.edgeR1); 259 | edgeCache.set(key, r); 260 | return r; 261 | }; 262 | const edges: Area[] = []; 263 | 264 | this.edges.forEach((e: cy.EdgeSingular) => { 265 | const ps = (e.segmentPoints() ?? [e.sourceEndpoint(), e.targetEndpoint()]).map((d) => ({ ...d })); 266 | if (ps.length === 0) { 267 | return; 268 | } 269 | const lines = ps.slice(1).map((next, i) => { 270 | const prev = ps[i]; 271 | return Line.from({ 272 | x1: prev.x, 273 | y1: prev.y, 274 | x2: next.x, 275 | y2: next.y, 276 | }); 277 | }); 278 | let data = (e.scratch(SCRATCH_KEY) ?? null) as IBubbleSetEdgeData | null; 279 | if (!data || potentialAreaDirty || !linesEquals(data.lines, lines)) { 280 | data = { 281 | lines, 282 | areas: lines.map(updateEdgeArea), 283 | }; 284 | e.scratch(SCRATCH_KEY, data); 285 | } 286 | edges.push(...data.areas); 287 | }); 288 | 289 | const memberShapes = members.map((d) => d.shape); 290 | if (this.#options.virtualEdges) { 291 | if (updateEdges) { 292 | const nonMembersShapes = nonMembers.map((d) => d.shape); 293 | const lines = calculateVirtualEdges( 294 | memberShapes, 295 | nonMembersShapes, 296 | this.#options.maxRoutingIterations, 297 | this.#options.morphBuffer 298 | ); 299 | this.#virtualEdgeAreas.clear(); 300 | lines.forEach((line) => { 301 | const area = updateEdgeArea(line); 302 | const key = toEdgeKey(line); 303 | this.#virtualEdgeAreas.set(key, area); 304 | edges.push(area); 305 | }); 306 | } else { 307 | this.#virtualEdgeAreas.forEach((area) => edges.push(area)); 308 | } 309 | } 310 | 311 | const memberAreas = members.filter((d): d is typeof d & { area: Area } => d.area != null).map((d) => d.area); 312 | const nonMemberAreas = nonMembers.filter((d): d is typeof d & { area: Area } => d.area != null).map((d) => d.area); 313 | const path = calculatePotentialOutline( 314 | potentialArea, 315 | memberAreas, 316 | edges, 317 | nonMemberAreas, 318 | (p) => p.containsElements(memberShapes), 319 | this.#options 320 | ); 321 | 322 | this.node.setAttribute('d', path.sample(8).simplify(0).bSplines().simplify(0).toString(2)); 323 | }; 324 | 325 | remove(): boolean { 326 | for (const set of [this.nodes, this.edges, this.avoidNodes]) { 327 | set.off('move position', undefined, this.#throttledUpdate); 328 | set.off('add', undefined, this.#adder); 329 | set.off('remove', undefined, this.#remover); 330 | set.forEach((d: cy.NodeSingular | cy.EdgeSingular) => { 331 | d.scratch(SCRATCH_KEY, {}); 332 | }); 333 | } 334 | 335 | this.node.remove(); 336 | return this.#adapter.remove(this); 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /src/BubbleSetsPlugin.ts: -------------------------------------------------------------------------------- 1 | import type cy from 'cytoscape'; 2 | import { layers, type ISVGLayer } from 'cytoscape-layers'; 3 | import BubbleSetPath, { type IBubbleSetPathOptions } from './BubbleSetPath'; 4 | 5 | export interface IBubbleSetsPluginOptions extends IBubbleSetPathOptions { 6 | layer?: ISVGLayer; 7 | } 8 | 9 | const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; 10 | 11 | export default class BubbleSetsPlugin { 12 | readonly layer: ISVGLayer; 13 | 14 | readonly #layers: BubbleSetPath[] = []; 15 | 16 | readonly #adapter = { 17 | remove: (path: BubbleSetPath): boolean => { 18 | const index = this.#layers.indexOf(path); 19 | if (index < 0) { 20 | return false; 21 | } 22 | this.#layers.splice(index, 1); 23 | return true; 24 | }, 25 | }; 26 | 27 | readonly #cy: cy.Core; 28 | 29 | readonly #options: IBubbleSetsPluginOptions; 30 | 31 | constructor(currentCy: cy.Core, options: IBubbleSetsPluginOptions = {}) { 32 | this.#cy = currentCy; 33 | this.#options = options; 34 | this.layer = options.layer ?? layers(currentCy).nodeLayer.insertBefore('svg'); 35 | } 36 | 37 | destroy(): void { 38 | for (const path of this.#layers) { 39 | path.remove(); 40 | } 41 | this.layer.remove(); 42 | } 43 | 44 | addPath( 45 | nodes: cy.NodeCollection, 46 | edges: cy.EdgeCollection | null = this.#cy.collection(), 47 | avoidNodes: cy.NodeCollection | null = this.#cy.collection(), 48 | options: IBubbleSetPathOptions = {} 49 | ): BubbleSetPath { 50 | const node = this.layer.node.ownerDocument.createElementNS(SVG_NAMESPACE, 'path'); 51 | this.layer.node.appendChild(node); 52 | const path = new BubbleSetPath( 53 | this.#adapter, 54 | node, 55 | nodes, 56 | edges ?? this.#cy.collection(), 57 | avoidNodes ?? this.#cy.collection(), 58 | { ...this.#options, ...options } 59 | ); 60 | this.#layers.push(path); 61 | path.update(); 62 | return path; 63 | } 64 | 65 | getPaths(): BubbleSetPath[] { 66 | return this.#layers.slice(); 67 | } 68 | 69 | removePath(path: BubbleSetPath): boolean { 70 | const i = this.#layers.indexOf(path); 71 | if (i < 0) { 72 | return false; 73 | } 74 | return path.remove(); 75 | } 76 | 77 | update(forceUpdate = false): void { 78 | this.#layers.forEach((p) => p.update(forceUpdate)); 79 | } 80 | } 81 | 82 | export function bubbleSets(this: cy.Core, options: IBubbleSetsPluginOptions = {}): BubbleSetsPlugin { 83 | return new BubbleSetsPlugin(this, options); 84 | } 85 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { bubbleSets } from './BubbleSetsPlugin'; 2 | 3 | export * from './BubbleSetsPlugin'; 4 | export { default as BubbleSetsPlugin } from './BubbleSetsPlugin'; 5 | export * from './BubbleSetPath'; 6 | export { default as BubbleSetPath } from './BubbleSetPath'; 7 | 8 | export type CytoscapeRegistry = { 9 | (type: 'core' | 'collection' | 'layout', name: string, extension: unknown): void; 10 | }; 11 | 12 | export default function register(cytoscape: CytoscapeRegistry): void { 13 | cytoscape('core', 'bubbleSets', bubbleSets); 14 | } 15 | 16 | function hasCytoscape(obj: unknown): obj is { cytoscape: CytoscapeRegistry } { 17 | return typeof (obj as { cytoscape: CytoscapeRegistry }).cytoscape === 'function'; 18 | } 19 | 20 | // auto register 21 | if (hasCytoscape(window)) { 22 | register(window.cytoscape); 23 | } 24 | 25 | // eslint-disable-next-line @typescript-eslint/no-namespace 26 | export declare namespace cytoscape { 27 | type Ext2 = (cytoscape: (type: 'core' | 'collection' | 'layout', name: string, extension: any) => void) => void; 28 | function use(module: Ext2): void; 29 | 30 | interface Core { 31 | bubbleSets: typeof bubbleSets; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.c.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "noEmit": true, 8 | "composite": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "ESNext", 5 | "lib": ["DOM", "ES2020"], 6 | "importHelpers": false, 7 | "declaration": false, 8 | "sourceMap": true, 9 | "strict": true, 10 | "removeComments": true, 11 | "verbatimModuleSyntax": false, 12 | "experimentalDecorators": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "strictBindCallApply": true, 15 | "stripInternal": true, 16 | "resolveJsonModule": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "moduleResolution": "node", 22 | "jsx": "react", 23 | "esModuleInterop": true, 24 | "rootDir": "./src", 25 | "baseUrl": "./", 26 | "noEmit": true, 27 | "paths": { 28 | "@": ["./src"], 29 | "*": ["*", "node_modules/*"] 30 | } 31 | }, 32 | "include": ["src/**/*.ts", "src/**/*.tsx", "docs/**/*.tsx"] 33 | } 34 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": ["./src"], 4 | "exclude": ["**/*.spec.ts"], 5 | "name": "cytoscape-bubblesets", 6 | "out": "./docs/", 7 | "readme": "none", 8 | "theme": "default", 9 | "excludeExternals": true, 10 | "excludeInternal": true, 11 | "excludePrivate": true, 12 | "includeVersion": true, 13 | "categorizeByGroup": true, 14 | "cleanOutputDir": true, 15 | "hideGenerator": true 16 | } 17 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | environment: 'jsdom', 7 | root: './src', 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------