├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── docs └── how-notion-unofficial-api-works.md ├── example0.png ├── graphcentral-graph.code-workspace ├── logo.png ├── package-lock.json ├── package.json ├── packages ├── example │ ├── .storybook │ │ ├── main.js │ │ └── preview.js │ ├── README.md │ ├── jest │ │ ├── jest.config.ts │ │ └── setupTest.js │ ├── package.json │ ├── public │ │ ├── index.html │ │ └── robots.txt │ ├── src │ │ ├── components │ │ │ ├── Example │ │ │ │ ├── fallback.tsx │ │ │ │ └── index.tsx │ │ │ └── Util │ │ │ │ └── WithErrorBoundary │ │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── normalize.css │ │ ├── styles.css │ │ └── utilities │ │ │ ├── essentials.ts │ │ │ └── query-params.ts │ ├── tsconfig.json │ └── webpack │ │ ├── webpack.config.common.ts │ │ ├── webpack.config.dev.ts │ │ └── webpack.config.prod.ts ├── graph │ ├── .gitignore │ ├── .storybook │ │ ├── main.js │ │ └── preview.js │ ├── README.md │ ├── example0.png │ ├── jest │ │ ├── jest.config.ts │ │ └── setupTest.js │ ├── logo.png │ ├── package.json │ ├── public │ │ ├── index.html │ │ └── robots.txt │ ├── src │ │ ├── components │ │ │ ├── Example │ │ │ │ ├── fallback.tsx │ │ │ │ └── index.tsx │ │ │ └── Util │ │ │ │ └── WithErrorBoundary │ │ │ │ └── index.tsx │ │ ├── fonts │ │ │ ├── roboto-regular.fnt │ │ │ └── roboto.fnt │ │ ├── index.css │ │ ├── index.ts │ │ ├── index.tsx │ │ ├── lib │ │ │ ├── common-graph-util.ts │ │ │ ├── conditional-node-labels-renderer.ts │ │ │ ├── db.ts │ │ │ ├── db.worker.ts │ │ │ ├── graph-enums.ts │ │ │ ├── graph-interaction.ts │ │ │ ├── graph.worker.ts │ │ │ ├── graphEvents.ts │ │ │ ├── index.ts │ │ │ ├── node-label.ts │ │ │ ├── setup-fps-monitor.ts │ │ │ └── types.ts │ │ └── utilities │ │ │ └── essentials.ts │ ├── tsconfig.d.ts.json │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsup.config.ts │ ├── webpack │ │ ├── README.md │ │ ├── legacy-v5 │ │ │ ├── webpack.config.common.ts │ │ │ ├── webpack.config.dev.ts │ │ │ └── webpack.config.prod.ts │ │ ├── webpack.config.dev.ts │ │ ├── webpack.config.lib.dev.ts │ │ ├── webpack.config.lib.prod.ts │ │ └── webpack.lib.config.gen.ts │ └── worker.d.ts └── test-data │ ├── README.md │ ├── data │ ├── notion-help-docs.json │ ├── prelayout-false-nodes-50000-links-61874.json │ ├── prelayout-false-nodes-501-links-1.json │ ├── prelayout-false-nodes-611-links-138.json │ ├── prelayout-true-nodes-55000-links-62499.json │ └── prelayout-true-notion-help-docs.json │ ├── force-graph-on-existing-data.mts │ ├── package.json │ ├── test-data-gen.mts │ └── tsconfig.json └── renovate.json /.eslintignore: -------------------------------------------------------------------------------- 1 | packages/test-data/**/* -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | root: true, 4 | parser: `@typescript-eslint/parser`, 5 | env: { 6 | browser: true, 7 | }, 8 | extends: [ 9 | `eslint:recommended`, 10 | `plugin:@typescript-eslint/eslint-recommended`, 11 | `plugin:@typescript-eslint/recommended`, 12 | `plugin:react/recommended`, 13 | `prettier`, 14 | ], 15 | // https://github.com/yannickcr/eslint-plugin-react#configuration 16 | settings: { 17 | react: { 18 | version: `detect`, 19 | }, 20 | }, 21 | parserOptions: { 22 | ecmaFeatures: { 23 | jsx: true, 24 | }, 25 | ecmaVersion: 12, 26 | sourceType: `module`, 27 | }, 28 | plugins: [`@typescript-eslint`, `react`, `prettier`], 29 | rules: { 30 | "prettier/prettier": `error`, 31 | "react/prop-types": 0, 32 | "linebreak-style": [`error`, `unix`], 33 | "arrow-body-style": `off`, 34 | "prefer-arrow-callback": `off`, 35 | "@typescript-eslint/ban-ts-comment": `off`, 36 | quotes: [`error`, `backtick`], 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | node_modules 6 | 7 | # testing 8 | /coverage 9 | 10 | # production 11 | /build 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | storybook-static 24 | 25 | .env 26 | dist -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.15.1 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "semi": false, 3 | "singleQuote": false, 4 | "tabWidth": 2, 5 | "printWidth": 80 6 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": true 4 | }, 5 | "eslint.validate": [ 6 | "javascript", 7 | "javascriptreact", 8 | "typescript", 9 | "typescriptreact" 10 | ], 11 | "files.associations": { 12 | "*.jsx": "javascriptreact", 13 | "*.tsx": "typescriptreact" 14 | }, 15 | "deno.path": "/Users/jm/.deno/bin/deno" 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Joel M 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 | # `@graphcentral/graph` 2 | 3 | ```bash 4 | npm i --save @graphcentral/graph 5 | ``` 6 | 7 | ![logo](./logo.png) 8 | 9 | ## Demo 10 | 11 | 👉 "5K nodes + 5K links": https://graphcentral.github.io/graph?graph_data=5000 12 | 13 | 👉 "50K nodes + 50K links": https://graphcentral.github.io/graph?graph_data=big 14 | 15 | 👉 "100K nodes + 100K links" (apprent degradation in performance expected): https://graphcentral.github.io/graph?graph_data=huge 16 | 17 | 👉 "Notion official help docs" (runs force layout algorithm on the browser): https://graphcentral.github.io/graph?graph_data=notion_docs 18 | 19 | ## Visualizing Notion pages 20 | 21 | You can visualize Notion pages on force layout graph using this library and `@graphcentral/notion` together.. Check out [@graphcentral/notion](https://github.com/graphcentral/notion). 22 | 23 | ## What you can get 24 | 25 | Example of a knowledge graph of Notion Help docs: 26 | ![example0.png](./example0.png) 27 | 28 | ## How to 29 | 30 | Simplest example: 31 | ```ts 32 | import { KnowledgeGraph } "@graphcentral/graph" 33 | 34 | const canvasElement = document.createElement(`canvas`) 35 | document.body.appendChild(canvasElement) 36 | 37 | const { nodes, links } = await fetch( 38 | `https://raw.githubusercontent.com/9oelM/datastore/main/notion-help-docs.json` 39 | ).then((resp) => resp.json()) 40 | 41 | if (!nodes || !links) { 42 | // error 43 | return 44 | } 45 | 46 | const knowledgeGraph = new KnowledgeGraph({ 47 | nodes: nodes, 48 | links: links, 49 | canvasElement, 50 | options: { 51 | optimization: { 52 | useParticleContainer: false, 53 | useShadowContainer: false, 54 | showEdgesOnCloseZoomOnly: true, 55 | useMouseHoverEffect: true, 56 | maxTargetFPS: 60, 57 | }, 58 | graph: { 59 | runForceLayout: true, 60 | customFont: { 61 | url: `https://fonts.googleapis.com/css2?family=Do+Hyeon&display=swap`, 62 | config: { 63 | fill: 0xffffff, 64 | fontFamily: `Do Hyeon`, 65 | }, 66 | }, 67 | }, 68 | }, 69 | }) 70 | knowledgeGraph.createNetworkGraph() 71 | ``` 72 | 73 | For more complicated example using `@graphcentral/graph`, visit `packages/example`. More docs, and interactive demo to come (contributions are most welcome). 74 | -------------------------------------------------------------------------------- /docs/how-notion-unofficial-api-works.md: -------------------------------------------------------------------------------- 1 | ## API 2 | 3 | ### `this.unofficialNotionAPI.getPage()` 4 | - Returns the list of the page itself and recurisve parents. 5 | - The order of the list reflects the parent-child relationship: for example, 6 | 7 | ``` 8 | "Simple testing ground" <-- the parent of everything 9 | 10 | ... 11 | 12 | "otherpage" <-- child of some page in the middle 13 | "otherpage-nested" <-- child of "otherpage" 14 | "also-nested" <-- child of "otherpage-nested" 15 | ``` 16 | 17 | the API call to `also-nested` in this structure of pages returns: 18 | 19 | ```json 20 | { 21 | "c9d98d57-b00f-4c36-88e0-5b6218e1b272": { 22 | "role": "reader", 23 | "value": { 24 | "id": "c9d98d57-b00f-4c36-88e0-5b6218e1b272", 25 | "version": 18, 26 | "type": "page", 27 | "properties": { 28 | "title": [ 29 | [ 30 | "also-nested" 31 | ] 32 | ] 33 | }, 34 | "created_time": 1655112151694, 35 | "last_edited_time": 1655112120000, 36 | "parent_id": "de2cb37e-994c-449b-8ee7-832805e1ca56", 37 | "parent_table": "block", 38 | "alive": true, 39 | "created_by_table": "notion_user", 40 | "created_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 41 | "last_edited_by_table": "notion_user", 42 | "last_edited_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 43 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 44 | } 45 | }, 46 | "de2cb37e-994c-449b-8ee7-832805e1ca56": { 47 | "role": "reader", 48 | "value": { 49 | "id": "de2cb37e-994c-449b-8ee7-832805e1ca56", 50 | "version": 27, 51 | "type": "page", 52 | "properties": { 53 | "title": [ 54 | [ 55 | "otherpage-nested" 56 | ] 57 | ] 58 | }, 59 | "content": [ 60 | "c9d98d57-b00f-4c36-88e0-5b6218e1b272" 61 | ], 62 | "created_time": 1655112019020, 63 | "last_edited_time": 1655112120000, 64 | "parent_id": "cfa04bab-3563-498b-9273-5a38b18f612e", 65 | "parent_table": "block", 66 | "alive": true, 67 | "created_by_table": "notion_user", 68 | "created_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 69 | "last_edited_by_table": "notion_user", 70 | "last_edited_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 71 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 72 | } 73 | }, 74 | "cfa04bab-3563-498b-9273-5a38b18f612e": { 75 | "role": "reader", 76 | "value": { 77 | "id": "cfa04bab-3563-498b-9273-5a38b18f612e", 78 | "version": 15, 79 | "type": "page", 80 | "properties": { 81 | "title": [ 82 | [ 83 | "otherpage" 84 | ] 85 | ] 86 | }, 87 | "content": [ 88 | "de2cb37e-994c-449b-8ee7-832805e1ca56" 89 | ], 90 | "created_time": 1655112015334, 91 | "last_edited_time": 1655112000000, 92 | "parent_id": "1f96a097-fd1a-4c53-a3c4-2a3288f39e9d", 93 | "parent_table": "block", 94 | "alive": true, 95 | "created_by_table": "notion_user", 96 | "created_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 97 | "last_edited_by_table": "notion_user", 98 | "last_edited_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 99 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 100 | } 101 | }, 102 | 103 | ... 104 | 105 | 106 | "1f96a097-fd1a-4c53-a3c4-2a3288f39e9d": { 107 | "role": "reader", 108 | "value": { 109 | "id": "1f96a097-fd1a-4c53-a3c4-2a3288f39e9d", 110 | "version": 140, 111 | "type": "page", 112 | "properties": { 113 | "title": [ 114 | [ 115 | "Simple testing ground" 116 | ] 117 | ] 118 | }, 119 | "content": [ 120 | "7d8579f7-78fb-48bc-871b-4efd9e1e795d", 121 | "72e6bb69-eeed-4971-8de8-b5acee2139da", 122 | "2ffa6e37-10f9-4ca3-b053-85807bcacb03", 123 | "cfa04bab-3563-498b-9273-5a38b18f612e" 124 | ], 125 | "format": { 126 | "page_icon": "🌥️" 127 | }, 128 | "permissions": [ 129 | { 130 | "role": "editor", 131 | "type": "user_permission", 132 | "user_id": "a5b8452a-14a3-4719-a224-ea678b37f50c" 133 | }, 134 | { 135 | "role": "reader", 136 | "type": "public_permission", 137 | "added_timestamp": 1655094244633 138 | } 139 | ], 140 | "created_time": 1655094180000, 141 | "last_edited_time": 1655112000000, 142 | "parent_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408", 143 | "parent_table": "space", 144 | "alive": true, 145 | "created_by_table": "notion_user", 146 | "created_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 147 | "last_edited_by_table": "notion_user", 148 | "last_edited_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 149 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 150 | } 151 | } 152 | } 153 | ``` 154 | 155 | The list will end with a block that is recursively topmost in the parent-child relationship. The last block therefore must have `"parent_table": "space"`. Note that the concept of topmost page is called `space` in the API. It is not fully equivalent to the topmost page though. Space is just an abstract concept. 156 | 157 | if you run request with the id of a page somewhere in the middle of parent-child chain, then you will get its recursive parents, and its first level child (not recursive). 158 | 159 | For example: 160 | 161 | ``` 162 | 1 163 | 2 164 | 3 165 | 4 166 | 5 167 | ``` 168 | 169 | A request with the id of 3 will return info in this order: 170 | 171 | ``` 172 | [ 173 | 3, 174 | 1, 175 | 2, 176 | 4 177 | ] 178 | ``` 179 | 180 | For another example with the same structure of pages, if a request with the id of 1 is sent, the response will be: 181 | 182 | ``` 183 | [ 184 | 1, 185 | 2 186 | ] 187 | ``` 188 | 189 | Therefore, a strategy to recursively get all blocks is to perform DFS. 190 | Note that other types of blocks may be present in between. We are only talking about `page` types here. 191 | 192 | ### `getBlocks(blockIds: string[])` 193 | 194 | ```ts 195 | client.getBlocks([ 196 | // some block id (like collection id or page id), possibly obtained from the result of getPage() 197 | separateIdWithDashSafe(`da588333932748dbbc4b-084ab6131aad`), 198 | ]), 199 | ``` 200 | 201 | blockIds are an array of ids that are separated by dash based on the format specified by the API. (Just use `separateIdWithDashSafe(id)`) 202 | 203 | returns a record map. 204 | 205 | #### Example return value of getBlocks([collectionId]) 206 | 207 | This returns 208 | 209 | ```ts 210 | { 211 | "recordMap": { 212 | "block": { 213 | "da588333-9327-48db-bc4b-084ab6131aad": { 214 | "role": "reader", 215 | "value": { 216 | "id": "da588333-9327-48db-bc4b-084ab6131aad", 217 | "version": 11, 218 | "type": "collection_view", 219 | "view_ids": [ 220 | "056b92f8-6fbc-4565-a0d2-597f03666fd8" 221 | ], 222 | "collection_id": "58e7440f-fad4-4a30-9de3-2dc5f5673b62", 223 | "format": { 224 | "collection_pointer": { 225 | "id": "58e7440f-fad4-4a30-9de3-2dc5f5673b62", 226 | "table": "collection", 227 | "spaceId": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 228 | } 229 | }, 230 | "created_time": 1655113911514, 231 | "last_edited_time": 1655113914028, 232 | "parent_id": "1f96a097-fd1a-4c53-a3c4-2a3288f39e9d", 233 | "parent_table": "block", 234 | "alive": true, 235 | "created_by_table": "notion_user", 236 | "created_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 237 | "last_edited_by_table": "notion_user", 238 | "last_edited_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 239 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 240 | } 241 | } 242 | } 243 | } 244 | } 245 | ``` 246 | 247 | #### Example return value of getBlocks([pageId]) 248 | 249 | ```ts 250 | { 251 | "recordMap": { 252 | "block": { 253 | "1f96a097-fd1a-4c53-a3c4-2a3288f39e9d": { 254 | "role": "reader", 255 | "value": { 256 | "id": "1f96a097-fd1a-4c53-a3c4-2a3288f39e9d", 257 | "version": 158, 258 | "type": "page", 259 | "properties": { 260 | "title": [ 261 | [ 262 | "Simple testing ground" 263 | ] 264 | ] 265 | }, 266 | "content": [ 267 | "cfa04bab-3563-498b-9273-5a38b18f612e", 268 | "da588333-9327-48db-bc4b-084ab6131aad" 269 | ], 270 | "format": { 271 | "page_icon": "🌥️" 272 | }, 273 | "permissions": [ 274 | { 275 | "role": "editor", 276 | "type": "user_permission", 277 | "user_id": "a5b8452a-14a3-4719-a224-ea678b37f50c" 278 | }, 279 | { 280 | "role": "reader", 281 | "type": "public_permission", 282 | "added_timestamp": 1655094244633 283 | } 284 | ], 285 | "created_time": 1655094180000, 286 | "last_edited_time": 1655113860000, 287 | "parent_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408", 288 | "parent_table": "space", 289 | "alive": true, 290 | "created_by_table": "notion_user", 291 | "created_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 292 | "last_edited_by_table": "notion_user", 293 | "last_edited_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 294 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 295 | } 296 | } 297 | } 298 | } 299 | } 300 | ``` 301 | 302 | ### `getCollectionData(collectionId: string, collectionViewId: string, collectionView: CollectionView)` 303 | 304 | First, you need to call `getBlocks()` to get collectionView. Let's pretend that we have below output from `getBlocks()`. 305 | 306 | ```ts 307 | const getBlocksResult = { 308 | "recordMap": { 309 | "block": { 310 | "da588333-9327-48db-bc4b-084ab6131aad": { 311 | "role": "reader", 312 | "value": { 313 | "id": "da588333-9327-48db-bc4b-084ab6131aad", 314 | "version": 11, 315 | "type": "collection_view", 316 | "view_ids": [ 317 | "056b92f8-6fbc-4565-a0d2-597f03666fd8" 318 | ], 319 | "collection_id": "58e7440f-fad4-4a30-9de3-2dc5f5673b62", 320 | "format": { 321 | "collection_pointer": { 322 | "id": "58e7440f-fad4-4a30-9de3-2dc5f5673b62", 323 | "table": "collection", 324 | "spaceId": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 325 | } 326 | }, 327 | "created_time": 1655113911514, 328 | "last_edited_time": 1655113914028, 329 | "parent_id": "1f96a097-fd1a-4c53-a3c4-2a3288f39e9d", 330 | "parent_table": "block", 331 | "alive": true, 332 | "created_by_table": "notion_user", 333 | "created_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 334 | "last_edited_by_table": "notion_user", 335 | "last_edited_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 336 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 337 | } 338 | } 339 | } 340 | } 341 | } 342 | ``` 343 | 344 | Then we just call 345 | 346 | ```ts 347 | const collectionBlock = getBlocksResult.recordMap.block[`da588333-9327-48db-bc4b-084ab6131aad`] 348 | const collectionData = await notionUnofficialClient.getCollectionData( 349 | // collection id from collectionBlock.value (equivalent to collectionBlock.value.collection_id) 350 | `58e7440f-fad4-4a30-9de3-2dc5f5673b62`, 351 | // collection id from collectionBlock.value (equivalent to collectionBlock.value.view_ids[0]) 352 | `056b92f8-6fbc-4565-a0d2-597f03666fd8`, 353 | collectionBlock.value 354 | ) 355 | ``` 356 | 357 | This returns something like 358 | 359 | ```json 360 | { 361 | "result": { 362 | "type": "reducer", 363 | "reducerResults": { 364 | "collection_group_results": { 365 | "type": "results", 366 | "blockIds": [ 367 | "73c7256e-f1b5-4fab-abc9-77536951ba33", 368 | "f3961580-1bd0-4fdc-a948-f16b450058d4", 369 | "11c93ec1-465d-4e4d-b331-419887cee722" 370 | ], 371 | "hasMore": false 372 | } 373 | }, 374 | "sizeHint": 3 375 | }, 376 | "recordMap": { 377 | "block": { 378 | "73c7256e-f1b5-4fab-abc9-77536951ba33": { 379 | "role": "reader", 380 | "value": { 381 | "id": "73c7256e-f1b5-4fab-abc9-77536951ba33", 382 | "version": 6, 383 | "type": "page", 384 | "properties": { 385 | "title": [ 386 | [ 387 | "db#1" 388 | ] 389 | ] 390 | }, 391 | "created_time": 1655113911514, 392 | "last_edited_time": 1655113860000, 393 | "parent_id": "58e7440f-fad4-4a30-9de3-2dc5f5673b62", 394 | "parent_table": "collection", 395 | "alive": true, 396 | "created_by_table": "notion_user", 397 | "created_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 398 | "last_edited_by_table": "notion_user", 399 | "last_edited_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 400 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 401 | } 402 | }, 403 | "f3961580-1bd0-4fdc-a948-f16b450058d4": { 404 | "role": "reader", 405 | "value": { 406 | "id": "f3961580-1bd0-4fdc-a948-f16b450058d4", 407 | "version": 9, 408 | "type": "page", 409 | "properties": { 410 | "title": [ 411 | [ 412 | "db#2" 413 | ] 414 | ] 415 | }, 416 | "content": [ 417 | "896ef183-f406-4750-9d10-d1fba05ce6b0" 418 | ], 419 | "created_time": 1655113911514, 420 | "last_edited_time": 1655114400000, 421 | "parent_id": "58e7440f-fad4-4a30-9de3-2dc5f5673b62", 422 | "parent_table": "collection", 423 | "alive": true, 424 | "created_by_table": "notion_user", 425 | "created_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 426 | "last_edited_by_table": "notion_user", 427 | "last_edited_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 428 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 429 | } 430 | }, 431 | "11c93ec1-465d-4e4d-b331-419887cee722": { 432 | "role": "reader", 433 | "value": { 434 | "id": "11c93ec1-465d-4e4d-b331-419887cee722", 435 | "version": 7, 436 | "type": "page", 437 | "properties": { 438 | "title": [ 439 | [ 440 | "db#3" 441 | ] 442 | ] 443 | }, 444 | "created_time": 1655113911514, 445 | "last_edited_time": 1655113920000, 446 | "parent_id": "58e7440f-fad4-4a30-9de3-2dc5f5673b62", 447 | "parent_table": "collection", 448 | "alive": true, 449 | "created_by_table": "notion_user", 450 | "created_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 451 | "last_edited_by_table": "notion_user", 452 | "last_edited_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 453 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 454 | } 455 | }, 456 | "da588333-9327-48db-bc4b-084ab6131aad": { 457 | "role": "reader", 458 | "value": { 459 | "id": "da588333-9327-48db-bc4b-084ab6131aad", 460 | "version": 16, 461 | "type": "collection_view", 462 | "view_ids": [ 463 | "056b92f8-6fbc-4565-a0d2-597f03666fd8" 464 | ], 465 | "collection_id": "58e7440f-fad4-4a30-9de3-2dc5f5673b62", 466 | "format": { 467 | "collection_pointer": { 468 | "id": "58e7440f-fad4-4a30-9de3-2dc5f5673b62", 469 | "table": "collection", 470 | "spaceId": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 471 | } 472 | }, 473 | "created_time": 1655113911514, 474 | "last_edited_time": 1655115421804, 475 | "parent_id": "1f96a097-fd1a-4c53-a3c4-2a3288f39e9d", 476 | "parent_table": "block", 477 | "alive": true, 478 | "created_by_table": "notion_user", 479 | "created_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 480 | "last_edited_by_table": "notion_user", 481 | "last_edited_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 482 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 483 | } 484 | }, 485 | "1f96a097-fd1a-4c53-a3c4-2a3288f39e9d": { 486 | "role": "reader", 487 | "value": { 488 | "id": "1f96a097-fd1a-4c53-a3c4-2a3288f39e9d", 489 | "version": 158, 490 | "type": "page", 491 | "properties": { 492 | "title": [ 493 | [ 494 | "Simple testing ground" 495 | ] 496 | ] 497 | }, 498 | "content": [ 499 | "cfa04bab-3563-498b-9273-5a38b18f612e", 500 | "da588333-9327-48db-bc4b-084ab6131aad" 501 | ], 502 | "format": { 503 | "page_icon": "🌥️" 504 | }, 505 | "permissions": [ 506 | { 507 | "role": "editor", 508 | "type": "user_permission", 509 | "user_id": "a5b8452a-14a3-4719-a224-ea678b37f50c" 510 | }, 511 | { 512 | "role": "reader", 513 | "type": "public_permission", 514 | "added_timestamp": 1655094244633 515 | } 516 | ], 517 | "created_time": 1655094180000, 518 | "last_edited_time": 1655113860000, 519 | "parent_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408", 520 | "parent_table": "space", 521 | "alive": true, 522 | "created_by_table": "notion_user", 523 | "created_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 524 | "last_edited_by_table": "notion_user", 525 | "last_edited_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 526 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 527 | } 528 | }, 529 | "896ef183-f406-4750-9d10-d1fba05ce6b0": { 530 | "role": "reader", 531 | "value": { 532 | "id": "896ef183-f406-4750-9d10-d1fba05ce6b0", 533 | "version": 14, 534 | "type": "text", 535 | "properties": { 536 | "title": [ 537 | [ 538 | "asdfdasfdsf" 539 | ] 540 | ] 541 | }, 542 | "created_time": 1655114400000, 543 | "last_edited_time": 1655114400000, 544 | "parent_id": "f3961580-1bd0-4fdc-a948-f16b450058d4", 545 | "parent_table": "block", 546 | "alive": true, 547 | "created_by_table": "notion_user", 548 | "created_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 549 | "last_edited_by_table": "notion_user", 550 | "last_edited_by_id": "a5b8452a-14a3-4719-a224-ea678b37f50c", 551 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 552 | } 553 | } 554 | }, 555 | "collection": { 556 | "58e7440f-fad4-4a30-9de3-2dc5f5673b62": { 557 | "role": "reader", 558 | "value": { 559 | "id": "58e7440f-fad4-4a30-9de3-2dc5f5673b62", 560 | "version": 14, 561 | "name": [ 562 | [ 563 | "Database-test" 564 | ] 565 | ], 566 | "schema": { 567 | "CT[O": { 568 | "name": "Tags", 569 | "type": "multi_select" 570 | }, 571 | "title": { 572 | "name": "Name", 573 | "type": "title" 574 | } 575 | }, 576 | "format": { 577 | "collection_page_properties": [ 578 | { 579 | "visible": true, 580 | "property": "CT[O" 581 | } 582 | ] 583 | }, 584 | "parent_id": "da588333-9327-48db-bc4b-084ab6131aad", 585 | "parent_table": "block", 586 | "alive": true, 587 | "migrated": true, 588 | "space_id": "6af4dea3-6d07-4986-9c7e-c2b8bc430408" 589 | } 590 | } 591 | }, 592 | "space": { 593 | "6af4dea3-6d07-4986-9c7e-c2b8bc430408": { 594 | "role": "none" 595 | } 596 | } 597 | } 598 | } 599 | ``` 600 | 601 | What's important is 602 | 603 | ```json 604 | "result": { 605 | "type": "reducer", 606 | "reducerResults": { 607 | "collection_group_results": { 608 | "type": "results", 609 | "blockIds": [ 610 | "73c7256e-f1b5-4fab-abc9-77536951ba33", 611 | "f3961580-1bd0-4fdc-a948-f16b450058d4", 612 | "11c93ec1-465d-4e4d-b331-419887cee722" 613 | ], 614 | "hasMore": false 615 | } 616 | }, 617 | "sizeHint": 3 618 | }, 619 | ``` 620 | 621 | this part, where `blockIds` is actual children pages of database, and `sizeHint` is the number of the children. 622 | 623 | ## Some relevant stuffs 624 | - https://observablehq.com/@zakjan/force-directed-graph-pixi 625 | - https://github.com/vasturiano/3d-force-graph 626 | - https://github.com/indradb/indradb -------------------------------------------------------------------------------- /example0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphcentral/graph/62dee94abfef6a32e85d1b6c90c0a08efcdca0a2/example0.png -------------------------------------------------------------------------------- /graphcentral-graph.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "eslint.workingDirectories": [ 9 | {"mode": "auto"} 10 | ] 11 | } 12 | } -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphcentral/graph/62dee94abfef6a32e85d1b6c90c0a08efcdca0a2/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@graphcentral/root", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "9oelM ", 6 | "license": "MIT", 7 | "private": false, 8 | "workspaces": { 9 | "packages": [ 10 | "packages/*" 11 | ] 12 | }, 13 | "scripts": { 14 | "lint": "eslint .", 15 | "lint:debug": "eslint . --debug", 16 | "lint:fix": "eslint . --fix", 17 | "graph:dev": "npm run dev -w packages/graph", 18 | "graph:build:dev": "npm run build:dev -w=packages/graph", 19 | "graph:build:prod": "npm run build:prod -w=packages/graph", 20 | "graph:package": "npm run package -w=packages/graph", 21 | "example:dev": "npm run dev -w packages/example", 22 | "example:prod": "npm run prod -w packages/example", 23 | "example:deploy": "npm run deploy -w packages/example", 24 | "scraper:dev": "npm run dev -w packages/notion-scraper", 25 | "scraper:compile": "npm run compile -w packages/notion-scraper", 26 | "test-data:gen": "npm run gen -w packages/test-data", 27 | "test-data:gen:existing": "npm run gen:existing -w packages/test-data" 28 | }, 29 | "devDependencies": { 30 | "@typescript-eslint/eslint-plugin": "5.9.1", 31 | "@typescript-eslint/parser": "5.9.1", 32 | "eslint": "8.6.0", 33 | "eslint-config-prettier": "8.3.0", 34 | "eslint-plugin-prettier": "4.0.0", 35 | "eslint-plugin-react": "7.28.0", 36 | "prettier": "2.5.1" 37 | }, 38 | "dependencies": { 39 | "react": "^18.2.0", 40 | "react-dom": "^18.2.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/example/.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const ForkTsCheckerWebpackPlugin = require(`fork-ts-checker-webpack-plugin`); 3 | const CircularDependencyPlugin = require('circular-dependency-plugin') 4 | 5 | module.exports = { 6 | "stories": [ 7 | "../src/**/*.stories.mdx", 8 | "../src/**/*.stories.@(js|jsx|ts|tsx)" 9 | ], 10 | "addons": [ 11 | "@storybook/addon-links", 12 | "@storybook/addon-essentials", 13 | '@storybook/addon-storysource', 14 | '@storybook/addon-a11y', 15 | ], 16 | webpackFinal: (config) => { 17 | config.plugins = [ 18 | ...config.plugins, 19 | new ForkTsCheckerWebpackPlugin(), 20 | new CircularDependencyPlugin({ 21 | // exclude detection of files based on a RegExp 22 | exclude: /a\.js|node_modules/, 23 | // include specific files based on a RegExp 24 | // include: /src/, 25 | // add errors to webpack instead of warnings 26 | failOnError: false, 27 | // allow import cycles that include an asyncronous import, 28 | // e.g. via import(/* webpackMode: "weak" */ './file.js') 29 | allowAsyncCycles: false, 30 | // set the current working directory for displaying module paths 31 | cwd: process.cwd(), 32 | }) 33 | ] 34 | // https://stackoverflow.com/questions/67070802/webpack-5-and-storybook-6-integration-throws-an-error-in-defineplugin-js 35 | config.resolve.fallback = { 36 | http: false, 37 | path: false, 38 | crypto: false, 39 | } 40 | 41 | return config; 42 | }, 43 | core: { 44 | builder: "webpack5", 45 | } 46 | } -------------------------------------------------------------------------------- /packages/example/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | 2 | export const parameters = { 3 | actions: { argTypesRegex: "^on[A-Z].*" }, 4 | controls: { 5 | matchers: { 6 | color: /(background|color)$/i, 7 | date: /Date$/, 8 | }, 9 | }, 10 | } -------------------------------------------------------------------------------- /packages/example/README.md: -------------------------------------------------------------------------------- 1 | # test 2 | 3 | This package is only used to test library import from `@graphcentral/graph` and to deploy examples. -------------------------------------------------------------------------------- /packages/example/jest/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types" 2 | 3 | const config: Config.InitialOptions = { 4 | verbose: true, 5 | rootDir: `..`, 6 | setupFiles: [`/jest/setupTest.js`], 7 | preset: `ts-jest`, 8 | testEnvironment: `jsdom`, 9 | } 10 | export default config 11 | -------------------------------------------------------------------------------- /packages/example/jest/setupTest.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | const Enzyme = require(`enzyme`) 4 | const Adapter = require(`@wojtekmaj/enzyme-adapter-react-17`) 5 | 6 | Enzyme.configure({ adapter: new Adapter() }) 7 | -------------------------------------------------------------------------------- /packages/example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@graphcentral/example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "9oelM ", 6 | "license": "MIT", 7 | "private": false, 8 | "scripts": { 9 | "test": "jest --config ./jest/jest.config.ts", 10 | "dev": "node --max-old-space-size=8192 ../../node_modules/.bin/webpack-dev-server --config ./webpack/webpack.config.dev.ts --mode development", 11 | "prod": "webpack --config ./webpack/webpack.config.prod.ts --mode production", 12 | "deploy": "rm -rf dist && npm run prod && gh-pages -d dist" 13 | }, 14 | "devDependencies": { 15 | "@babel/core": "7.16.7", 16 | "@jest/types": "27.4.2", 17 | "@storybook/addon-a11y": "6.4.10", 18 | "@storybook/addon-actions": "6.4.10", 19 | "@storybook/addon-essentials": "6.4.10", 20 | "@storybook/addon-links": "6.4.10", 21 | "@storybook/addon-storysource": "6.4.10", 22 | "@storybook/builder-webpack5": "6.4.10", 23 | "@storybook/manager-webpack5": "^6.4.10", 24 | "@storybook/react": "6.4.10", 25 | "@types/enzyme": "3.10.11", 26 | "@types/fontfaceobserver": "^2.1.0", 27 | "@types/html-webpack-plugin": "^3.2.6", 28 | "@types/jest": "27.4.0", 29 | "@types/lodash.flow": "3.5.6", 30 | "@types/react": "17.0.38", 31 | "@types/react-dom": "17.0.11", 32 | "@types/stats.js": "^0.17.0", 33 | "@types/webfontloader": "^1.6.34", 34 | "@types/webpack-dev-server": "4.7.1", 35 | "@wojtekmaj/enzyme-adapter-react-17": "0.6.6", 36 | "babel-loader": "8.2.3", 37 | "circular-dependency-plugin": "5.2.2", 38 | "css-loader": "^6.7.1", 39 | "dotenv-webpack": "7.0.3", 40 | "enzyme": "3.11.0", 41 | "extract-loader": "^5.1.0", 42 | "fork-ts-checker-webpack-plugin": "6.5.0", 43 | "gh-pages": "^4.0.0", 44 | "html-loader": "^4.1.0", 45 | "html-webpack-plugin": "5.5.0", 46 | "jest": "27.4.7", 47 | "style-loader": "^3.3.1", 48 | "ts-jest": "27.1.2", 49 | "ts-loader": "9.2.6", 50 | "ts-node": "10.4.0", 51 | "tsup": "^6.1.3", 52 | "typescript": "4.5.4", 53 | "wait-for-expect": "3.0.2", 54 | "webpack": "5.65.0", 55 | "webpack-cli": "4.9.1", 56 | "webpack-dev-server": "4.7.2", 57 | "worker-loader": "^3.0.8" 58 | }, 59 | "dependencies": { 60 | "@graphcentral/graph": "^0.1.0-rc.4", 61 | "axios": "^0.24.0", 62 | "react": "^17.0.2", 63 | "react-dom": "^17.0.2" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | @graphcentral/graph demo 13 | 15 | 16 | 17 | 18 | Star me on GitHub 19 |
20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /packages/example/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /packages/example/src/components/Example/fallback.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { FC } from "react" 3 | 4 | export const ExampleFallback: FC = () => ( 5 |
6 |

11 | Oops. Something went wrong. Please try again. 12 |

13 |
14 | ) 15 | -------------------------------------------------------------------------------- /packages/example/src/components/Example/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | MutableRefObject, 3 | useCallback, 4 | useEffect, 5 | useRef, 6 | useState, 7 | } from "react" 8 | import { FC } from "react" 9 | import { enhance, tcAsync } from "../../utilities/essentials" 10 | import { ExampleFallback } from "./fallback" 11 | import { Node, Link, KnowledgeGraph, WithCoords } from "@graphcentral/graph" 12 | import { getQueryParams } from "../../utilities/query-params" 13 | 14 | enum Errors { 15 | IMPORT_FAILED = `IMPORT_FAILED`, 16 | FETCH_TEST_DATA_FAILED = `FETCH_TEST_DATA_FAILED`, 17 | } 18 | 19 | enum LoadStatus { 20 | IMPORT_LIB = `IMPORT_LIB`, 21 | FETCH_DATA = `FETCH_DATA`, 22 | LAYOUT = `LAYOUT`, 23 | DB = `DB`, 24 | LABEL = `LABEL`, 25 | } 26 | 27 | const LoadStatusToText: Record = { 28 | IMPORT_LIB: `Importing library...`, 29 | FETCH_DATA: `Fetching graph data...`, 30 | LAYOUT: `Loading layout...`, 31 | DB: `Loading DB (You can see labels after this)...`, 32 | LABEL: `Loading labels...`, 33 | } 34 | 35 | type JSONResponse = { 36 | nodes?: Node[] 37 | links?: Link[] 38 | errors?: Array<{ message: string }> 39 | } 40 | 41 | const preStyle = { 42 | width: `300px`, 43 | } 44 | 45 | const NotionNodeListItem: FC<{ 46 | node: WithCoords 47 | knowledgeGraphRef: MutableRefObject>, 49 | Link 50 | > | null> 51 | }> = enhance<{ 52 | node: WithCoords 53 | knowledgeGraphRef: MutableRefObject>, 55 | Link 56 | > | null> 57 | }>(({ node, knowledgeGraphRef }) => { 58 | const [isDropdownOpen, setDropdownOpen] = useState(false) 59 | const openNotionPageOnClick = useCallback( 60 | () => window.open(`https://notion.so/${node.id.replaceAll(`-`, ``)}`), 61 | [node.id] 62 | ) 63 | const onListItemClick = useCallback( 64 | () => setDropdownOpen((prev) => !prev), 65 | [] 66 | ) 67 | const onNavigateToNode = useCallback(() => { 68 | knowledgeGraphRef.current?.moveTo({ x: node.x, y: node.y }) 69 | }, [node.x, node.y]) 70 | 71 | return ( 72 | <> 73 |
78 |

{node.title}

79 |
80 | {isDropdownOpen ? ( 81 |
86 | 89 | 92 |
{JSON.stringify(node, undefined, 2)}
93 |
94 | ) : null} 95 | 96 | ) 97 | })() 98 | 99 | // eslint-disable-next-line @typescript-eslint/ban-types 100 | export const Example: FC<{}> = enhance<{}>(() => { 101 | const canvasElement = useRef(null) 102 | const [errors, setErrors] = useState() 103 | 104 | const [loadStatuses, setLoadStatuses] = useState< 105 | Record 106 | >({ 107 | [LoadStatus.IMPORT_LIB]: false, 108 | [LoadStatus.FETCH_DATA]: false, 109 | [LoadStatus.DB]: false, 110 | [LoadStatus.LABEL]: true, 111 | [LoadStatus.LAYOUT]: false, 112 | }) 113 | const [clickedAndLinkedNodes, setClickedAndLinkedNodes] = useState<{ 114 | node: WithCoords 115 | 116 | linkedNodes: WithCoords[] 117 | }>() 118 | const knowledgeGraphRef = useRef | null>(null) 119 | useEffect(() => { 120 | ;(async () => { 121 | if (!canvasElement.current) return 122 | const [err, imported] = await tcAsync(import(`@graphcentral/graph`)) 123 | if (err || !imported) { 124 | setErrors((prev) => 125 | prev ? [...prev, Errors.IMPORT_FAILED] : [Errors.IMPORT_FAILED] 126 | ) 127 | return 128 | } 129 | setLoadStatuses((prev) => ({ 130 | ...prev, 131 | [LoadStatus.IMPORT_LIB]: true, 132 | })) 133 | 134 | const { KnowledgeGraph } = imported 135 | const graphInfo: { 136 | graphDataUrl: string 137 | runForceLayout?: boolean 138 | useParticleContainer?: boolean 139 | useShadowContainer?: boolean 140 | } = (() => { 141 | const queryParams = getQueryParams() 142 | const test = queryParams[`graph_data`] 143 | switch (test) { 144 | case `huge`: { 145 | return { 146 | useParticleContainer: true, 147 | useShadowContainer: true, 148 | graphDataUrl: `https://raw.githubusercontent.com/9oelM/datastore/main/prelayout-true-nodes-100000-links-118749.json`, 149 | } 150 | } 151 | case `big`: { 152 | return { 153 | useParticleContainer: true, 154 | useShadowContainer: true, 155 | graphDataUrl: `https://raw.githubusercontent.com/9oelM/datastore/main/prelayout-true-nodes-50000-links-49999.json`, 156 | } 157 | } 158 | case `5000`: { 159 | return { 160 | graphDataUrl: `https://raw.githubusercontent.com/9oelM/datastore/main/prelayout-true-nodes-5100-links-6249.json`, 161 | } 162 | } 163 | case `notion_docs`: 164 | default: 165 | return { 166 | runForceLayout: false, 167 | graphDataUrl: `https://raw.githubusercontent.com/9oelM/datastore/main/prelayout-true-notion-help-docs.json`, 168 | } 169 | } 170 | })() 171 | const sampleDataResp = await fetch(graphInfo.graphDataUrl) 172 | // `https://raw.githubusercontent.com/9oelM/datastore/main/3000ish.json` 173 | const [sampleDataJsonErr, sampleDataJson] = await tcAsync<{ 174 | nodes: Node[] 175 | links: Link[] 176 | }>(sampleDataResp.json()) 177 | 178 | if (sampleDataJsonErr || !sampleDataJson) { 179 | setErrors((prev) => 180 | prev 181 | ? [...prev, Errors.FETCH_TEST_DATA_FAILED] 182 | : [Errors.FETCH_TEST_DATA_FAILED] 183 | ) 184 | 185 | return 186 | } 187 | setLoadStatuses((prev) => ({ 188 | ...prev, 189 | [LoadStatus.FETCH_DATA]: true, 190 | })) 191 | const { nodes, links } = sampleDataJson 192 | const knowledgeGraph = new KnowledgeGraph({ 193 | nodes: nodes, 194 | links: links, 195 | canvasElement: canvasElement.current, 196 | options: { 197 | optimization: { 198 | useParticleContainer: Boolean(graphInfo.useParticleContainer), 199 | useShadowContainer: Boolean(graphInfo.useShadowContainer), 200 | showEdgesOnCloseZoomOnly: true, 201 | useMouseHoverEffect: true, 202 | maxTargetFPS: 60, 203 | }, 204 | graph: { 205 | runForceLayout: Boolean(graphInfo.runForceLayout), 206 | customFont: { 207 | url: `https://fonts.googleapis.com/css2?family=Do+Hyeon&display=swap`, 208 | config: { 209 | fill: 0xffffff, 210 | fontFamily: `Do Hyeon`, 211 | }, 212 | }, 213 | }, 214 | }, 215 | }) 216 | knowledgeGraphRef.current = knowledgeGraph 217 | knowledgeGraph.createNetworkGraph() 218 | knowledgeGraph.graphEventEmitter.on(`finishDb`, () => { 219 | setLoadStatuses((prevStatuses) => ({ 220 | ...prevStatuses, 221 | [LoadStatus.DB]: true, 222 | })) 223 | }) 224 | knowledgeGraph.graphEventEmitter.on(`finishLayout`, () => { 225 | setLoadStatuses((prevStatuses) => ({ 226 | ...prevStatuses, 227 | [LoadStatus.LAYOUT]: true, 228 | })) 229 | }) 230 | knowledgeGraph.graphEventEmitter.on(`finishLabels`, () => { 231 | setLoadStatuses((prevStatuses) => ({ 232 | ...prevStatuses, 233 | [LoadStatus.LABEL]: true, 234 | })) 235 | }) 236 | knowledgeGraph.graphEventEmitter.on(`startLabels`, () => { 237 | setLoadStatuses((prevStatuses) => ({ 238 | ...prevStatuses, 239 | [LoadStatus.LABEL]: false, 240 | })) 241 | }) 242 | knowledgeGraph.graphEventEmitter.on(`clickNode`, setClickedAndLinkedNodes) 243 | })() 244 | }, []) 245 | 246 | const isFetchingData = !loadStatuses.FETCH_DATA || !loadStatuses.IMPORT_LIB 247 | 248 | return ( 249 |
257 | {isFetchingData ? ( 258 |
263 |
276 |

282 | Fetching data... please wait 283 |

284 |
285 |
286 | ) : null} 287 |
292 |
305 |

313 | Drag/scroll to navigate and click to inspect nodes. 314 |
Works best on desktop for now 315 |

316 |
317 |
318 | 319 | {clickedAndLinkedNodes ? ( 320 |
331 | {

Clicked:

} 332 | 336 | {

Linked:

} 337 | {clickedAndLinkedNodes?.linkedNodes.map((node) => { 338 | return ( 339 | 344 | ) 345 | })} 346 |
347 | ) : null} 348 |
356 | {Object.entries(loadStatuses).map(([loadTarget, isLoaded]) => { 357 | if (isLoaded) { 358 | return null 359 | } 360 | return ( 361 |

362 | {LoadStatusToText[loadTarget as keyof typeof LoadStatus]} 363 |

364 | ) 365 | })} 366 |
367 |
368 | ) 369 | })(ExampleFallback) 370 | -------------------------------------------------------------------------------- /packages/example/src/components/Util/WithErrorBoundary/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | /* eslint-disable @typescript-eslint/ban-types */ 3 | import React, { ComponentType, FC, memo } from "react" 4 | import { ErrorInfo, PureComponent, ReactNode } from "react" 5 | 6 | export const NullFallback: FC = () => null 7 | 8 | export type ErrorBoundaryProps = { 9 | Fallback: ReactNode 10 | } 11 | 12 | export type ErrorBoundaryState = { 13 | error?: Error 14 | errorInfo?: ErrorInfo 15 | } 16 | 17 | export class ErrorBoundary extends PureComponent< 18 | ErrorBoundaryProps, 19 | ErrorBoundaryState 20 | > { 21 | constructor(props: ErrorBoundaryProps) { 22 | super(props) 23 | this.state = { error: undefined, errorInfo: undefined } 24 | } 25 | 26 | public componentDidCatch(error: Error, errorInfo: ErrorInfo): void { 27 | this.setState({ 28 | error: error, 29 | errorInfo: errorInfo, 30 | }) 31 | /** 32 | * @todo log Sentry here 33 | */ 34 | } 35 | 36 | public render(): ReactNode { 37 | if (this.state.error) return this.props.Fallback 38 | return this.props.children 39 | } 40 | } 41 | 42 | export function withErrorBoundary(Component: ComponentType) { 43 | return (Fallback = NullFallback) => { 44 | // eslint-disable-next-line react/display-name 45 | return memo(({ ...props }: Props) => { 46 | return ( 47 | }> 48 | 49 | 50 | ) 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import { Example } from "./components/Example" 4 | import "./normalize.css" 5 | import "./styles.css" 6 | 7 | ReactDOM.render(, document.getElementById(`root`)) 8 | -------------------------------------------------------------------------------- /packages/example/src/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } 350 | -------------------------------------------------------------------------------- /packages/example/src/styles.css: -------------------------------------------------------------------------------- 1 | 2 | html, body, #root { 3 | overflow:hidden; 4 | margin: 0; 5 | padding: 0; 6 | width: 100%; 7 | height: 100%; 8 | } 9 | 10 | * { 11 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 12 | margin: 0; 13 | padding: 0; 14 | } 15 | 16 | pre { 17 | white-space: pre-wrap; /* Since CSS 2.1 */ 18 | white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ 19 | white-space: -pre-wrap; /* Opera 4-6 */ 20 | white-space: -o-pre-wrap; /* Opera 7 */ 21 | word-wrap: break-word; /* Internet Explorer 5.5+ */ 22 | color: #ffffffb0; 23 | } 24 | 25 | h2 { 26 | font-weight: 400; 27 | margin-top: 1rem; 28 | margin-bottom: 0.5rem; 29 | color: #ffffffb0; 30 | } 31 | 32 | .button { 33 | padding: 0.25rem; 34 | cursor: pointer; 35 | margin: 0.25rem; 36 | border: 1px solid #ffffffb0; 37 | background: transparent; 38 | color: #ffffffb0; 39 | } 40 | 41 | .notion-node-list-item { 42 | padding: 0.5rem; 43 | color: #ffffffb0; 44 | cursor: pointer; 45 | border: 1px solid #ffffffb0; 46 | } 47 | 48 | .notion-node-list-item:hover, .button:hover { 49 | background: #343434; 50 | transition: background-color 300ms ease-in-out; 51 | } 52 | 53 | .loading-text { 54 | width: 100%; 55 | color: #ffffffb0; 56 | } -------------------------------------------------------------------------------- /packages/example/src/utilities/essentials.ts: -------------------------------------------------------------------------------- 1 | import { FC, memo } from "react" 2 | import { withErrorBoundary } from "../components/Util/WithErrorBoundary" 3 | import flow from "lodash.flow" 4 | 5 | export const enhance: ( 6 | Component: FC 7 | ) => ( 8 | Fallback?: FC 9 | ) => React.MemoExoticComponent<({ ...props }: Props) => JSX.Element> = flow( 10 | memo, 11 | withErrorBoundary 12 | ) 13 | 14 | export type TcResult = [null, Data] | [Throws] 15 | 16 | export async function tcAsync( 17 | promise: Promise 18 | ): Promise> { 19 | try { 20 | const response: T = await promise 21 | 22 | return [null, response] 23 | } catch (error) { 24 | return [error] as [Throws] 25 | } 26 | } 27 | 28 | export function tcSync< 29 | ArrType, 30 | Params extends Array, 31 | Returns, 32 | Throws = Error 33 | >( 34 | fn: (...params: Params) => Returns, 35 | ...deps: Params 36 | ): TcResult { 37 | try { 38 | const data: Returns = fn(...deps) 39 | 40 | return [null, data] 41 | } catch (e) { 42 | return [e] as [Throws] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/example/src/utilities/query-params.ts: -------------------------------------------------------------------------------- 1 | export function getQueryParams() { 2 | const urlSearchParams = new URLSearchParams(window.location.search) 3 | return Object.fromEntries(urlSearchParams.entries()) 4 | } 5 | -------------------------------------------------------------------------------- /packages/example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | // these options are overrides used only by ts-node 4 | // same as the --compilerOptions flag and the TS_NODE_COMPILER_OPTIONS environment variable 5 | "compilerOptions": { 6 | "module": "commonjs" 7 | } 8 | }, 9 | "compilerOptions": { 10 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 11 | 12 | /* Basic Options */ 13 | // "incremental": true, /* Enable incremental compilation */ 14 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 15 | "module": "ES2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 16 | "lib": ["DOM", "ESNext"], /* Specify library files to be included in the compilation. */ 17 | // "allowJs": true, /* Allow javascript files to be compiled. */ 18 | // "checkJs": true, /* Report errors in .js files. */ 19 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 20 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 21 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 22 | "sourceMap": true, /* Generates corresponding '.map' file. */ 23 | // "outFile": "./", /* Concatenate and emit output to single file. */ 24 | // "outDir": "./", /* Redirect output structure to the directory. */ 25 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 26 | // "composite": true, /* Enable project compilation */ 27 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 28 | // "removeComments": true, /* Do not emit comments to output. */ 29 | // "noEmit": true, /* Do not emit outputs. */ 30 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 31 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 32 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 33 | 34 | /* Strict Type-Checking Options */ 35 | "strict": true, /* Enable all strict type-checking options. */ 36 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 37 | "strictNullChecks": true, /* Enable strict null checks. */ 38 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 39 | "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 40 | "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 41 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 42 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 43 | 44 | /* Additional Checks */ 45 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 46 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 47 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 48 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 49 | "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 50 | "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 51 | 52 | /* Module Resolution Options */ 53 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 54 | "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 55 | "paths": { 56 | "src/*": [ 57 | "./src/*" 58 | ] 59 | }, 60 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 61 | // "typeRoots": [], /* List of folders to include type definitions from. */ 62 | // "types": [], /* Type declaration files to be included in compilation. */ 63 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 64 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 65 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 66 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 67 | 68 | /* Source Map Options */ 69 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 70 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 71 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 72 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 73 | 74 | /* Experimental Options */ 75 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 76 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 77 | 78 | /* Advanced Options */ 79 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 80 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 81 | "resolveJsonModule": true 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /packages/example/webpack/webpack.config.common.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import webpack from "webpack" 3 | import HtmlWebpackPlugin from "html-webpack-plugin" 4 | 5 | const optimization: webpack.Configuration[`optimization`] = { 6 | runtimeChunk: `multiple`, 7 | splitChunks: { 8 | chunks: `all`, 9 | name: `shared`, 10 | cacheGroups: { 11 | vendor: { 12 | test: /[\\/]node_modules[\\/]/, 13 | //@ts-ignore 14 | name(module) { 15 | // get the name. E.g. node_modules/packageName/not/this/part.js 16 | // or node_modules/packageName 17 | const packageName = module.context.match( 18 | /[\\/]node_modules[\\/](.*?)([\\/]|$)/ 19 | )[1] 20 | 21 | // npm package names are URL-safe, but some servers don't like @ symbols 22 | return `npm.${packageName.replace(`@`, ``)}` 23 | }, 24 | }, 25 | }, 26 | }, 27 | } 28 | 29 | export const commonConfig: webpack.Configuration = { 30 | entry: `./src/index.tsx`, 31 | // https://webpack.js.org/plugins/split-chunks-plugin/ 32 | optimization, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.tsx?$/, 37 | use: `ts-loader`, 38 | exclude: /node_modules/, 39 | }, 40 | { 41 | test: /\.css?$/, 42 | use: [`style-loader`, `css-loader`], 43 | }, 44 | { 45 | test: /\.(fnt)$/i, 46 | type: `asset/resource`, 47 | generator: { 48 | filename: `static/[name].fnt`, 49 | }, 50 | }, 51 | ], 52 | }, 53 | resolve: { 54 | extensions: [`.tsx`, `.ts`, `.js`], 55 | }, 56 | output: { 57 | filename: `[chunkhash].[name].js`, 58 | path: path.resolve(__dirname, `..`, `dist`), 59 | }, 60 | plugins: [ 61 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 62 | // @ts-ignore 63 | new HtmlWebpackPlugin({ 64 | template: path.join(__dirname, `..`, `public`, `index.html`), 65 | }), 66 | ], 67 | } 68 | -------------------------------------------------------------------------------- /packages/example/webpack/webpack.config.dev.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import webpack from "webpack" 3 | // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/27570#issuecomment-437115227 4 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 5 | // @ts-ignore 6 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 7 | import * as webpackDevServer from "webpack-dev-server" 8 | import { commonConfig } from "./webpack.config.common" 9 | 10 | const config: webpack.Configuration = { 11 | mode: `development`, 12 | devtool: `inline-source-map`, 13 | devServer: { 14 | static: path.join(__dirname, `dist`), 15 | // publicPath: path.resolve(`static`), 16 | compress: true, 17 | port: 8080, 18 | open: true, 19 | }, 20 | watch: true, 21 | ...commonConfig, 22 | } 23 | 24 | export default [config] 25 | -------------------------------------------------------------------------------- /packages/example/webpack/webpack.config.prod.ts: -------------------------------------------------------------------------------- 1 | import webpack from "webpack" 2 | import { commonConfig } from "./webpack.config.common" 3 | 4 | const config: webpack.Configuration = { 5 | mode: `production`, 6 | ...commonConfig, 7 | } 8 | 9 | export default [config] 10 | -------------------------------------------------------------------------------- /packages/graph/.gitignore: -------------------------------------------------------------------------------- 1 | declarations -------------------------------------------------------------------------------- /packages/graph/.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const ForkTsCheckerWebpackPlugin = require(`fork-ts-checker-webpack-plugin`); 3 | const CircularDependencyPlugin = require('circular-dependency-plugin') 4 | 5 | module.exports = { 6 | "stories": [ 7 | "../src/**/*.stories.mdx", 8 | "../src/**/*.stories.@(js|jsx|ts|tsx)" 9 | ], 10 | "addons": [ 11 | "@storybook/addon-links", 12 | "@storybook/addon-essentials", 13 | '@storybook/addon-storysource', 14 | '@storybook/addon-a11y', 15 | ], 16 | webpackFinal: (config) => { 17 | config.plugins = [ 18 | ...config.plugins, 19 | new ForkTsCheckerWebpackPlugin(), 20 | new CircularDependencyPlugin({ 21 | // exclude detection of files based on a RegExp 22 | exclude: /a\.js|node_modules/, 23 | // include specific files based on a RegExp 24 | // include: /src/, 25 | // add errors to webpack instead of warnings 26 | failOnError: false, 27 | // allow import cycles that include an asyncronous import, 28 | // e.g. via import(/* webpackMode: "weak" */ './file.js') 29 | allowAsyncCycles: false, 30 | // set the current working directory for displaying module paths 31 | cwd: process.cwd(), 32 | }) 33 | ] 34 | // https://stackoverflow.com/questions/67070802/webpack-5-and-storybook-6-integration-throws-an-error-in-defineplugin-js 35 | config.resolve.fallback = { 36 | http: false, 37 | path: false, 38 | crypto: false, 39 | } 40 | 41 | return config; 42 | }, 43 | core: { 44 | builder: "webpack5", 45 | } 46 | } -------------------------------------------------------------------------------- /packages/graph/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | 2 | export const parameters = { 3 | actions: { argTypesRegex: "^on[A-Z].*" }, 4 | controls: { 5 | matchers: { 6 | color: /(background|color)$/i, 7 | date: /Date$/, 8 | }, 9 | }, 10 | } -------------------------------------------------------------------------------- /packages/graph/README.md: -------------------------------------------------------------------------------- 1 | # `@graphcentral/graph` 2 | 3 | ```bash 4 | npm i --save @graphcentral/graph 5 | ``` 6 | 7 | ![logo](./logo.png) 8 | 9 | ## Demo 10 | 11 | 👉 https://graphcentral.github.io/graph 12 | 13 | ## Visualizing Notion pages 14 | 15 | You can visualize Notion pages on force layout graph using this library and `@graphcentral/notion` together.. Check out [@graphcentral/notion](https://github.com/graphcentral/notion). 16 | 17 | ## What you can get 18 | 19 | Example of a knowledge graph of Notion Help docs: 20 | ![example0.png](./example0.png) 21 | 22 | ## How to 23 | 24 | Simplest example: 25 | ```ts 26 | import { KnowledgeGraph } "@graphcentral/graph" 27 | 28 | const canvasElement = document.createElement(`canvas`) 29 | document.body.appendChild(canvasElement) 30 | 31 | const { nodes, links } = await fetch( 32 | `https://raw.githubusercontent.com/9oelM/datastore/main/notion-help-docs.json` 33 | ).then((resp) => resp.json()) 34 | 35 | if (!nodes || !links) { 36 | // error 37 | return 38 | } 39 | 40 | const knowledgeGraph = new KnowledgeGraph({ 41 | nodes: nodes, 42 | links: links, 43 | canvasElement, 44 | options: { 45 | optimization: { 46 | useParticleContainer: false, 47 | useShadowContainer: false, 48 | showEdgesOnCloseZoomOnly: true, 49 | useMouseHoverEffect: true, 50 | maxTargetFPS: 60, 51 | }, 52 | graph: { 53 | runForceLayout: true, 54 | customFont: { 55 | url: `https://fonts.googleapis.com/css2?family=Do+Hyeon&display=swap`, 56 | config: { 57 | fill: 0xffffff, 58 | fontFamily: `Do Hyeon`, 59 | }, 60 | }, 61 | }, 62 | }, 63 | }) 64 | knowledgeGraph.createNetworkGraph() 65 | ``` 66 | 67 | For more complicated example using `@graphcentral/graph`, visit `packages/example`. More docs, and interactive demo to come (contributions are most welcome). 68 | -------------------------------------------------------------------------------- /packages/graph/example0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphcentral/graph/62dee94abfef6a32e85d1b6c90c0a08efcdca0a2/packages/graph/example0.png -------------------------------------------------------------------------------- /packages/graph/jest/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types" 2 | 3 | const config: Config.InitialOptions = { 4 | verbose: true, 5 | rootDir: `..`, 6 | setupFiles: [`/jest/setupTest.js`], 7 | preset: `ts-jest`, 8 | testEnvironment: `jsdom`, 9 | } 10 | export default config 11 | -------------------------------------------------------------------------------- /packages/graph/jest/setupTest.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | const Enzyme = require(`enzyme`) 4 | const Adapter = require(`@wojtekmaj/enzyme-adapter-react-17`) 5 | 6 | Enzyme.configure({ adapter: new Adapter() }) 7 | -------------------------------------------------------------------------------- /packages/graph/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphcentral/graph/62dee94abfef6a32e85d1b6c90c0a08efcdca0a2/packages/graph/logo.png -------------------------------------------------------------------------------- /packages/graph/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@graphcentral/graph", 3 | "version": "0.1.0-rc.4", 4 | "main": "dist/main.js", 5 | "types": "declarations/index.d.ts", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/graphcentral/graph.git" 9 | }, 10 | "files": ["dist", "declarations"], 11 | "author": "9oelM ", 12 | "license": "MIT", 13 | "private": false, 14 | "scripts": { 15 | "test": "jest --config ./jest/jest.config.ts", 16 | "dev": "webpack-dev-server --config ./webpack/webpack.config.dev.ts --mode development --progress", 17 | "build:dev": "webpack --watch --config ./webpack/webpack.config.lib.dev.ts --mode development --progress", 18 | "build:prod": "webpack --config ./webpack/webpack.config.lib.prod.ts --mode production --progress", 19 | "build:defs": "tsc --declaration --outDir ./declarations --emitDeclarationOnly --project tsconfig.d.ts.json", 20 | "package": "rm -rf declarations && rm -rf dist && npm run build:prod && npm run build:defs" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "7.16.7", 24 | "@jest/types": "27.4.2", 25 | "@storybook/addon-a11y": "6.4.10", 26 | "@storybook/addon-actions": "6.4.10", 27 | "@storybook/addon-essentials": "6.4.10", 28 | "@storybook/addon-links": "6.4.10", 29 | "@storybook/addon-storysource": "6.4.10", 30 | "@storybook/builder-webpack5": "6.4.10", 31 | "@storybook/manager-webpack5": "^6.4.10", 32 | "@storybook/react": "6.4.10", 33 | "@types/enzyme": "3.10.11", 34 | "@types/fontfaceobserver": "^2.1.0", 35 | "@types/html-webpack-plugin": "^3.2.6", 36 | "@types/jest": "27.4.0", 37 | "@types/lodash.flow": "3.5.6", 38 | "@types/react": "17.0.38", 39 | "@types/react-dom": "17.0.11", 40 | "@types/stats.js": "^0.17.0", 41 | "@types/webfontloader": "^1.6.34", 42 | "@types/webpack-bundle-analyzer": "^4.4.1", 43 | "@types/webpack-dev-server": "4.7.1", 44 | "@wojtekmaj/enzyme-adapter-react-17": "0.6.6", 45 | "babel-loader": "8.2.3", 46 | "circular-dependency-plugin": "5.2.2", 47 | "css-loader": "^6.7.1", 48 | "dotenv-webpack": "7.0.3", 49 | "enzyme": "3.11.0", 50 | "esbuild-loader": "^2.19.0", 51 | "extract-loader": "^5.1.0", 52 | "fork-ts-checker-webpack-plugin": "6.5.0", 53 | "html-loader": "^4.1.0", 54 | "html-webpack-plugin": "5.5.0", 55 | "jest": "27.4.7", 56 | "process": "^0.11.10", 57 | "style-loader": "^3.3.1", 58 | "ts-jest": "27.1.2", 59 | "ts-loader": "9.2.6", 60 | "ts-node": "10.4.0", 61 | "tsup": "^6.1.3", 62 | "typescript": "4.5.4", 63 | "wait-for-expect": "3.0.2", 64 | "webpack": "^4.46.0", 65 | "webpack-bundle-analyzer": "^4.5.0", 66 | "webpack-cli": "4.9.1", 67 | "webpack-dev-server": "4.7.2", 68 | "worker-loader": "^3.0.8" 69 | }, 70 | "dependencies": { 71 | "@pixi-essentials/cull": "^1.1.0", 72 | "await-to-js": "^3.0.0", 73 | "color-hash": "^2.0.1", 74 | "d3-force": "^3.0.0", 75 | "d3-force-reuse": "^1.0.1", 76 | "dexie": "^3.2.2", 77 | "ee-ts": "^1.0.2", 78 | "lodash.flow": "^3.5.0", 79 | "pixi-viewport": "4.24", 80 | "pixi-webfont-loader": "^1.0.2", 81 | "pixi.js": "^6.4.2", 82 | "react": "^17.0.2", 83 | "react-dom": "^17.0.2", 84 | "webfontloader": "^1.6.28" 85 | }, 86 | "bugs": { 87 | "url": "https://github.com/graphcentral/graph/issues" 88 | }, 89 | "homepage": "https://github.com/graphcentral/graph#readme", 90 | "description": "Performant graph visualization on the web with WebGL + Webworkers" 91 | } 92 | -------------------------------------------------------------------------------- /packages/graph/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Notion knowledge graph 12 | 14 | 15 | 16 | 17 |
18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/graph/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /packages/graph/src/components/Example/fallback.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { FC } from "react" 3 | 4 | export const ExampleFallback: FC = () => ( 5 |
6 |

11 | Oops. Something went wrong. Please try again. 12 |

13 |
14 | ) 15 | -------------------------------------------------------------------------------- /packages/graph/src/components/Example/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect, useRef } from "react" 2 | import { FC } from "react" 3 | import { KnowledgeGraph } from "../../lib" 4 | import { enhance } from "../../utilities/essentials" 5 | import { ExampleFallback } from "./fallback" 6 | // import testData from "../../../../test-data/test11.json" 7 | 8 | // eslint-disable-next-line @typescript-eslint/ban-types 9 | export const Example: FC<{}> = enhance<{}>(() => { 10 | const canvasElement = useRef(null) 11 | useLayoutEffect(() => { 12 | ;(async () => { 13 | if (!canvasElement.current) return 14 | const testData = await fetch( 15 | // `https://raw.githubusercontent.com/9oelM/datastore/main/prelayout-true-nodes-100000-links-118749.json` 16 | // `https://raw.githubusercontent.com/9oelM/datastore/main/3000ish.json` 17 | // `https://raw.githubusercontent.com/9oelM/datastore/main/notion-help-docs.json` 18 | // `https://raw.githubusercontent.com/9oelM/datastore/main/prelayout-true-nodes-5100-links-6249.json` 19 | `https://raw.githubusercontent.com/9oelM/datastore/main/prelayout-true-notion-help-docs.json` 20 | ).then((resp) => resp.json()) 21 | 22 | const knowledgeGraph = new KnowledgeGraph({ 23 | // @ts-ignore 24 | nodes: testData.nodes, 25 | // @ts-ignore 26 | links: testData.links, 27 | canvasElement: canvasElement.current, 28 | options: { 29 | optimization: { 30 | useParticleContainer: true, 31 | useShadowContainer: true, 32 | showEdgesOnCloseZoomOnly: true, 33 | useMouseHoverEffect: true, 34 | maxTargetFPS: 60, 35 | }, 36 | graph: { 37 | runForceLayout: false, 38 | customFont: { 39 | url: `https://fonts.googleapis.com/css2?family=Do+Hyeon&display=swap`, 40 | config: { 41 | fill: 0xffffff, 42 | fontFamily: `Do Hyeon`, 43 | }, 44 | }, 45 | }, 46 | }, 47 | }) 48 | knowledgeGraph.createNetworkGraph() 49 | })() 50 | }, []) 51 | 52 | return 53 | })(ExampleFallback) 54 | -------------------------------------------------------------------------------- /packages/graph/src/components/Util/WithErrorBoundary/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | /* eslint-disable @typescript-eslint/ban-types */ 3 | import React, { ComponentType, FC, memo } from "react" 4 | import { ErrorInfo, PureComponent, ReactNode } from "react" 5 | 6 | export const NullFallback: FC = () => null 7 | 8 | export type ErrorBoundaryProps = { 9 | Fallback: ReactNode 10 | } 11 | 12 | export type ErrorBoundaryState = { 13 | error?: Error 14 | errorInfo?: ErrorInfo 15 | } 16 | 17 | export class ErrorBoundary extends PureComponent< 18 | ErrorBoundaryProps, 19 | ErrorBoundaryState 20 | > { 21 | constructor(props: ErrorBoundaryProps) { 22 | super(props) 23 | this.state = { error: undefined, errorInfo: undefined } 24 | } 25 | 26 | public componentDidCatch(error: Error, errorInfo: ErrorInfo): void { 27 | this.setState({ 28 | error: error, 29 | errorInfo: errorInfo, 30 | }) 31 | /** 32 | * @todo log Sentry here 33 | */ 34 | } 35 | 36 | public render(): ReactNode { 37 | if (this.state.error) return this.props.Fallback 38 | return this.props.children 39 | } 40 | } 41 | 42 | export function withErrorBoundary(Component: ComponentType) { 43 | return (Fallback = NullFallback) => { 44 | // eslint-disable-next-line react/display-name 45 | return memo(({ ...props }: Props) => { 46 | return ( 47 | }> 48 | 49 | 50 | ) 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/graph/src/fonts/roboto-regular.fnt: -------------------------------------------------------------------------------- 1 | info face="Roboto" size=32 bold=0 italic=0 charset="" unicode=0 stretchH=100 smooth=1 aa=1 padding=1,1,1,1 spacing=-2,-2 2 | common lineHeight=38 base=30 scaleW=256 scaleH=256 pages=1 packed=0 3 | page id=0 file="roboto-400.png" 4 | chars count=98 5 | char id=0 x=0 y=0 width=0 height=0 xoffset=-1 yoffset=0 xadvance=0 page=0 chnl=0 6 | char id=10 x=0 y=0 width=0 height=0 xoffset=-1 yoffset=0 xadvance=0 page=0 chnl=0 7 | char id=32 x=0 y=0 width=0 height=0 xoffset=-1 yoffset=0 xadvance=8 page=0 chnl=0 8 | char id=33 x=248 y=62 width=6 height=26 xoffset=1 yoffset=5 xadvance=8 page=0 chnl=0 9 | char id=34 x=208 y=140 width=9 height=11 xoffset=1 yoffset=4 xadvance=10 page=0 chnl=0 10 | char id=35 x=204 y=88 width=21 height=26 xoffset=0 yoffset=5 xadvance=20 page=0 chnl=0 11 | char id=36 x=103 y=0 width=18 height=32 xoffset=0 yoffset=2 xadvance=18 page=0 chnl=0 12 | char id=37 x=88 y=114 width=24 height=25 xoffset=0 yoffset=6 xadvance=23 page=0 chnl=0 13 | char id=38 x=225 y=88 width=21 height=26 xoffset=0 yoffset=5 xadvance=20 page=0 chnl=0 14 | char id=39 x=250 y=0 width=5 height=10 xoffset=0 yoffset=4 xadvance=6 page=0 chnl=0 15 | char id=40 x=0 y=0 width=11 height=35 xoffset=1 yoffset=3 xadvance=11 page=0 chnl=0 16 | char id=41 x=11 y=0 width=11 height=35 xoffset=-1 yoffset=3 xadvance=11 page=0 chnl=0 17 | char id=42 x=130 y=140 width=16 height=17 xoffset=-1 yoffset=5 xadvance=14 page=0 chnl=0 18 | char id=43 x=112 y=140 width=18 height=19 xoffset=0 yoffset=10 xadvance=18 page=0 chnl=0 19 | char id=44 x=217 y=140 width=7 height=11 xoffset=-1 yoffset=25 xadvance=6 page=0 chnl=0 20 | char id=45 x=6 y=160 width=11 height=5 xoffset=-1 yoffset=17 xadvance=9 page=0 chnl=0 21 | char id=46 x=0 y=160 width=6 height=5 xoffset=1 yoffset=26 xadvance=8 page=0 chnl=0 22 | char id=47 x=149 y=0 width=15 height=28 xoffset=-1 yoffset=5 xadvance=13 page=0 chnl=0 23 | char id=48 x=70 y=114 width=18 height=25 xoffset=0 yoffset=6 xadvance=18 page=0 chnl=0 24 | char id=49 x=70 y=88 width=12 height=26 xoffset=1 yoffset=5 xadvance=18 page=0 chnl=0 25 | char id=50 x=82 y=88 width=18 height=26 xoffset=0 yoffset=5 xadvance=18 page=0 chnl=0 26 | char id=51 x=35 y=114 width=17 height=25 xoffset=0 yoffset=6 xadvance=18 page=0 chnl=0 27 | char id=52 x=100 y=88 width=20 height=26 xoffset=-1 yoffset=5 xadvance=18 page=0 chnl=0 28 | char id=53 x=120 y=88 width=17 height=26 xoffset=1 yoffset=5 xadvance=18 page=0 chnl=0 29 | char id=54 x=137 y=88 width=17 height=26 xoffset=1 yoffset=5 xadvance=18 page=0 chnl=0 30 | char id=55 x=154 y=88 width=18 height=26 xoffset=0 yoffset=5 xadvance=18 page=0 chnl=0 31 | char id=56 x=52 y=114 width=18 height=25 xoffset=0 yoffset=6 xadvance=18 page=0 chnl=0 32 | char id=57 x=172 y=88 width=17 height=26 xoffset=0 yoffset=5 xadvance=18 page=0 chnl=0 33 | char id=58 x=246 y=114 width=6 height=19 xoffset=1 yoffset=12 xadvance=8 page=0 chnl=0 34 | char id=59 x=247 y=35 width=8 height=24 xoffset=-1 yoffset=12 xadvance=7 page=0 chnl=0 35 | char id=60 x=146 y=140 width=15 height=16 xoffset=0 yoffset=12 xadvance=16 page=0 chnl=0 36 | char id=61 x=192 y=140 width=16 height=12 xoffset=1 yoffset=12 xadvance=18 page=0 chnl=0 37 | char id=62 x=161 y=140 width=16 height=16 xoffset=1 yoffset=12 xadvance=17 page=0 chnl=0 38 | char id=63 x=189 y=88 width=15 height=26 xoffset=0 yoffset=5 xadvance=15 page=0 chnl=0 39 | char id=64 x=74 y=0 width=29 height=32 xoffset=0 yoffset=6 xadvance=29 page=0 chnl=0 40 | char id=65 x=84 y=35 width=23 height=26 xoffset=-1 yoffset=5 xadvance=21 page=0 chnl=0 41 | char id=66 x=107 y=35 width=19 height=26 xoffset=1 yoffset=5 xadvance=20 page=0 chnl=0 42 | char id=67 x=14 y=114 width=21 height=25 xoffset=0 yoffset=6 xadvance=21 page=0 chnl=0 43 | char id=68 x=126 y=35 width=20 height=26 xoffset=1 yoffset=5 xadvance=21 page=0 chnl=0 44 | char id=69 x=146 y=35 width=18 height=26 xoffset=1 yoffset=5 xadvance=18 page=0 chnl=0 45 | char id=70 x=164 y=35 width=17 height=26 xoffset=1 yoffset=5 xadvance=18 page=0 chnl=0 46 | char id=71 x=181 y=35 width=21 height=26 xoffset=0 yoffset=5 xadvance=22 page=0 chnl=0 47 | char id=72 x=202 y=35 width=21 height=26 xoffset=1 yoffset=5 xadvance=23 page=0 chnl=0 48 | char id=73 x=223 y=35 width=6 height=26 xoffset=1 yoffset=5 xadvance=9 page=0 chnl=0 49 | char id=74 x=229 y=35 width=18 height=26 xoffset=-1 yoffset=5 xadvance=18 page=0 chnl=0 50 | char id=75 x=0 y=62 width=20 height=26 xoffset=1 yoffset=5 xadvance=20 page=0 chnl=0 51 | char id=76 x=20 y=62 width=17 height=26 xoffset=1 yoffset=5 xadvance=17 page=0 chnl=0 52 | char id=77 x=37 y=62 width=26 height=26 xoffset=1 yoffset=5 xadvance=28 page=0 chnl=0 53 | char id=78 x=63 y=62 width=21 height=26 xoffset=1 yoffset=5 xadvance=23 page=0 chnl=0 54 | char id=79 x=84 y=62 width=22 height=26 xoffset=0 yoffset=5 xadvance=22 page=0 chnl=0 55 | char id=80 x=106 y=62 width=19 height=26 xoffset=1 yoffset=5 xadvance=20 page=0 chnl=0 56 | char id=81 x=121 y=0 width=22 height=30 xoffset=0 yoffset=5 xadvance=22 page=0 chnl=0 57 | char id=82 x=125 y=62 width=20 height=26 xoffset=1 yoffset=5 xadvance=20 page=0 chnl=0 58 | char id=83 x=145 y=62 width=19 height=26 xoffset=0 yoffset=5 xadvance=19 page=0 chnl=0 59 | char id=84 x=164 y=62 width=21 height=26 xoffset=-1 yoffset=5 xadvance=19 page=0 chnl=0 60 | char id=85 x=185 y=62 width=19 height=26 xoffset=1 yoffset=5 xadvance=21 page=0 chnl=0 61 | char id=86 x=204 y=62 width=22 height=26 xoffset=-1 yoffset=5 xadvance=20 page=0 chnl=0 62 | char id=87 x=0 y=88 width=30 height=26 xoffset=-1 yoffset=5 xadvance=28 page=0 chnl=0 63 | char id=88 x=226 y=62 width=22 height=26 xoffset=-1 yoffset=5 xadvance=20 page=0 chnl=0 64 | char id=89 x=30 y=88 width=21 height=26 xoffset=-1 yoffset=5 xadvance=19 page=0 chnl=0 65 | char id=90 x=51 y=88 width=19 height=26 xoffset=0 yoffset=5 xadvance=19 page=0 chnl=0 66 | char id=91 x=22 y=0 width=9 height=34 xoffset=1 yoffset=2 xadvance=8 page=0 chnl=0 67 | char id=92 x=164 y=0 width=15 height=28 xoffset=-1 yoffset=5 xadvance=13 page=0 chnl=0 68 | char id=93 x=31 y=0 width=9 height=34 xoffset=-1 yoffset=2 xadvance=8 page=0 chnl=0 69 | char id=94 x=177 y=140 width=15 height=14 xoffset=-1 yoffset=5 xadvance=13 page=0 chnl=0 70 | char id=95 x=17 y=160 width=17 height=5 xoffset=-1 yoffset=28 xadvance=14 page=0 chnl=0 71 | char id=96 x=244 y=140 width=9 height=8 xoffset=0 yoffset=4 xadvance=10 page=0 chnl=0 72 | char id=97 x=125 y=114 width=17 height=20 xoffset=0 yoffset=11 xadvance=17 page=0 chnl=0 73 | char id=98 x=179 y=0 width=17 height=27 xoffset=1 yoffset=4 xadvance=18 page=0 chnl=0 74 | char id=99 x=142 y=114 width=17 height=20 xoffset=0 yoffset=11 xadvance=17 page=0 chnl=0 75 | char id=100 x=196 y=0 width=17 height=27 xoffset=0 yoffset=4 xadvance=18 page=0 chnl=0 76 | char id=101 x=159 y=114 width=17 height=20 xoffset=0 yoffset=11 xadvance=17 page=0 chnl=0 77 | char id=102 x=213 y=0 width=14 height=27 xoffset=-1 yoffset=4 xadvance=11 page=0 chnl=0 78 | char id=103 x=227 y=0 width=17 height=27 xoffset=0 yoffset=11 xadvance=18 page=0 chnl=0 79 | char id=104 x=0 y=35 width=16 height=27 xoffset=1 yoffset=4 xadvance=18 page=0 chnl=0 80 | char id=105 x=246 y=88 width=6 height=25 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=0 81 | char id=106 x=40 y=0 width=10 height=33 xoffset=-2 yoffset=5 xadvance=8 page=0 chnl=0 82 | char id=107 x=16 y=35 width=17 height=27 xoffset=1 yoffset=4 xadvance=16 page=0 chnl=0 83 | char id=108 x=244 y=0 width=6 height=27 xoffset=1 yoffset=4 xadvance=8 page=0 chnl=0 84 | char id=109 x=176 y=114 width=26 height=20 xoffset=1 yoffset=11 xadvance=28 page=0 chnl=0 85 | char id=110 x=202 y=114 width=16 height=20 xoffset=1 yoffset=11 xadvance=18 page=0 chnl=0 86 | char id=111 x=94 y=140 width=18 height=19 xoffset=0 yoffset=12 xadvance=18 page=0 chnl=0 87 | char id=112 x=33 y=35 width=17 height=27 xoffset=1 yoffset=11 xadvance=18 page=0 chnl=0 88 | char id=113 x=50 y=35 width=17 height=27 xoffset=0 yoffset=11 xadvance=18 page=0 chnl=0 89 | char id=114 x=218 y=114 width=11 height=20 xoffset=1 yoffset=11 xadvance=11 page=0 chnl=0 90 | char id=115 x=229 y=114 width=17 height=20 xoffset=0 yoffset=11 xadvance=17 page=0 chnl=0 91 | char id=116 x=112 y=114 width=13 height=24 xoffset=-2 yoffset=7 xadvance=10 page=0 chnl=0 92 | char id=117 x=0 y=140 width=16 height=20 xoffset=1 yoffset=11 xadvance=18 page=0 chnl=0 93 | char id=118 x=16 y=140 width=18 height=20 xoffset=-1 yoffset=11 xadvance=16 page=0 chnl=0 94 | char id=119 x=34 y=140 width=26 height=20 xoffset=-1 yoffset=11 xadvance=24 page=0 chnl=0 95 | char id=120 x=60 y=140 width=18 height=20 xoffset=-1 yoffset=11 xadvance=16 page=0 chnl=0 96 | char id=121 x=67 y=35 width=17 height=27 xoffset=-1 yoffset=11 xadvance=15 page=0 chnl=0 97 | char id=122 x=78 y=140 width=16 height=20 xoffset=0 yoffset=11 xadvance=16 page=0 chnl=0 98 | char id=123 x=50 y=0 width=12 height=33 xoffset=0 yoffset=4 xadvance=11 page=0 chnl=0 99 | char id=124 x=143 y=0 width=6 height=30 xoffset=1 yoffset=5 xadvance=8 page=0 chnl=0 100 | char id=125 x=62 y=0 width=12 height=33 xoffset=-1 yoffset=4 xadvance=11 page=0 chnl=0 101 | char id=126 x=224 y=140 width=20 height=9 xoffset=1 yoffset=15 xadvance=22 page=0 chnl=0 102 | char id=127 x=0 y=114 width=14 height=26 xoffset=0 yoffset=5 xadvance=14 page=0 chnl=0 103 | kernings count=158 104 | kerning first=89 second=117 amount=-1 105 | kerning first=114 second=46 amount=-2 106 | kerning first=39 second=39 amount=-2 107 | kerning first=88 second=45 amount=-1 108 | kerning first=76 second=85 amount=-1 109 | kerning first=84 second=74 amount=-4 110 | kerning first=65 second=87 amount=-1 111 | kerning first=76 second=119 amount=-1 112 | kerning first=89 second=109 amount=-1 113 | kerning first=65 second=121 amount=-1 114 | kerning first=86 second=100 amount=-1 115 | kerning first=86 second=45 amount=-1 116 | kerning first=70 second=46 amount=-4 117 | kerning first=39 second=65 amount=-2 118 | kerning first=84 second=32 amount=-1 119 | kerning first=84 second=121 amount=-1 120 | kerning first=121 second=44 amount=-2 121 | kerning first=39 second=99 amount=-1 122 | kerning first=86 second=113 amount=-1 123 | kerning first=89 second=101 amount=-1 124 | kerning first=84 second=100 amount=-2 125 | kerning first=89 second=46 amount=-3 126 | kerning first=84 second=45 amount=-4 127 | kerning first=79 second=44 amount=-2 128 | kerning first=68 second=46 amount=-2 129 | kerning first=34 second=111 amount=-1 130 | kerning first=80 second=74 amount=-3 131 | kerning first=89 second=114 amount=-1 132 | kerning first=119 second=44 amount=-2 133 | kerning first=84 second=113 amount=-2 134 | kerning first=111 second=34 amount=-2 135 | kerning first=87 second=46 amount=-2 136 | kerning first=65 second=84 amount=-2 137 | kerning first=34 second=103 amount=-1 138 | kerning first=65 second=118 amount=-1 139 | kerning first=86 second=97 amount=-1 140 | kerning first=109 second=34 amount=-2 141 | kerning first=65 second=63 amount=-1 142 | kerning first=89 second=85 amount=-1 143 | kerning first=84 second=118 amount=-1 144 | kerning first=111 second=39 amount=-2 145 | kerning first=46 second=34 amount=-3 146 | kerning first=84 second=97 amount=-2 147 | kerning first=114 second=116 amount=1 148 | kerning first=82 second=84 amount=-1 149 | kerning first=76 second=87 amount=-2 150 | kerning first=65 second=89 amount=-1 151 | kerning first=76 second=121 amount=-2 152 | kerning first=65 second=34 amount=-2 153 | kerning first=89 second=111 amount=-1 154 | kerning first=84 second=110 amount=-2 155 | kerning first=109 second=39 amount=-2 156 | kerning first=44 second=34 amount=-3 157 | kerning first=76 second=79 amount=-1 158 | kerning first=121 second=46 amount=-2 159 | kerning first=39 second=101 amount=-1 160 | kerning first=34 second=100 amount=-1 161 | kerning first=46 second=39 amount=-3 162 | kerning first=89 second=103 amount=-1 163 | kerning first=79 second=46 amount=-2 164 | kerning first=82 second=89 amount=-1 165 | kerning first=34 second=113 amount=-1 166 | kerning first=65 second=39 amount=-2 167 | kerning first=119 second=46 amount=-2 168 | kerning first=84 second=115 amount=-2 169 | kerning first=76 second=71 amount=-1 170 | kerning first=70 second=74 amount=-4 171 | kerning first=44 second=39 amount=-3 172 | kerning first=76 second=84 amount=-4 173 | kerning first=89 second=74 amount=-1 174 | kerning first=65 second=86 amount=-1 175 | kerning first=86 second=65 amount=-1 176 | kerning first=76 second=118 amount=-2 177 | kerning first=86 second=99 amount=-1 178 | kerning first=86 second=44 amount=-4 179 | kerning first=84 second=120 amount=-1 180 | kerning first=84 second=65 amount=-1 181 | kerning first=34 second=97 amount=-1 182 | kerning first=89 second=100 amount=-1 183 | kerning first=84 second=99 amount=-2 184 | kerning first=89 second=45 amount=-1 185 | kerning first=32 second=84 amount=-1 186 | kerning first=84 second=44 amount=-3 187 | kerning first=97 second=34 amount=-1 188 | kerning first=76 second=89 amount=-4 189 | kerning first=39 second=111 amount=-1 190 | kerning first=76 second=34 amount=-5 191 | kerning first=114 second=97 amount=-1 192 | kerning first=89 second=113 amount=-1 193 | kerning first=84 second=112 amount=-2 194 | kerning first=87 second=45 amount=-1 195 | kerning first=34 second=34 amount=-2 196 | kerning first=76 second=81 amount=-1 197 | kerning first=39 second=103 amount=-1 198 | kerning first=75 second=119 amount=-1 199 | kerning first=80 second=65 amount=-2 200 | kerning first=97 second=39 amount=-1 201 | kerning first=80 second=44 amount=-5 202 | kerning first=70 second=97 amount=-1 203 | kerning first=76 second=39 amount=-5 204 | kerning first=34 second=115 amount=-1 205 | kerning first=84 second=117 amount=-1 206 | kerning first=89 second=97 amount=-1 207 | kerning first=34 second=39 amount=-2 208 | kerning first=89 second=42 amount=-1 209 | kerning first=76 second=86 amount=-3 210 | kerning first=118 second=44 amount=-2 211 | kerning first=89 second=110 amount=-1 212 | kerning first=84 second=109 amount=-2 213 | kerning first=86 second=101 amount=-1 214 | kerning first=110 second=34 amount=-2 215 | kerning first=87 second=97 amount=-1 216 | kerning first=86 second=46 amount=-4 217 | kerning first=68 second=89 amount=-1 218 | kerning first=34 second=65 amount=-2 219 | kerning first=84 second=122 amount=-1 220 | kerning first=39 second=100 amount=-1 221 | kerning first=34 second=99 amount=-1 222 | kerning first=84 second=101 amount=-2 223 | kerning first=84 second=46 amount=-3 224 | kerning first=39 second=113 amount=-1 225 | kerning first=66 second=89 amount=-1 226 | kerning first=89 second=115 amount=-1 227 | kerning first=47 second=47 amount=-3 228 | kerning first=84 second=114 amount=-1 229 | kerning first=114 second=44 amount=-2 230 | kerning first=110 second=39 amount=-2 231 | kerning first=81 second=84 amount=-1 232 | kerning first=75 second=121 amount=-1 233 | kerning first=76 second=117 amount=-1 234 | kerning first=65 second=119 amount=-1 235 | kerning first=70 second=65 amount=-3 236 | kerning first=104 second=34 amount=-2 237 | kerning first=80 second=46 amount=-5 238 | kerning first=75 second=45 amount=-1 239 | kerning first=70 second=44 amount=-4 240 | kerning first=84 second=119 amount=-1 241 | kerning first=89 second=65 amount=-1 242 | kerning first=39 second=97 amount=-1 243 | kerning first=86 second=111 amount=-1 244 | kerning first=89 second=99 amount=-1 245 | kerning first=89 second=44 amount=-3 246 | kerning first=81 second=89 amount=-1 247 | kerning first=68 second=44 amount=-2 248 | kerning first=118 second=46 amount=-2 249 | kerning first=89 second=112 amount=-1 250 | kerning first=87 second=65 amount=-1 251 | kerning first=84 second=111 amount=-2 252 | kerning first=76 second=67 amount=-1 253 | kerning first=86 second=103 amount=-1 254 | kerning first=104 second=39 amount=-2 255 | kerning first=39 second=34 amount=-2 256 | kerning first=87 second=44 amount=-2 257 | kerning first=79 second=89 amount=-1 258 | kerning first=34 second=101 amount=-1 259 | kerning first=75 second=118 amount=-1 260 | kerning first=84 second=103 amount=-2 261 | kerning first=39 second=115 amount=-1 -------------------------------------------------------------------------------- /packages/graph/src/fonts/roboto.fnt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphcentral/graph/62dee94abfef6a32e85d1b6c90c0a08efcdca0a2/packages/graph/src/fonts/roboto.fnt -------------------------------------------------------------------------------- /packages/graph/src/index.css: -------------------------------------------------------------------------------- 1 | html, body, #root { 2 | overflow:hidden; 3 | margin: 0; 4 | padding: 0; 5 | } -------------------------------------------------------------------------------- /packages/graph/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./lib" 2 | export * from "./lib/common-graph-util" 3 | export * from "./lib/conditional-node-labels-renderer" 4 | export * from "./lib/db" 5 | export * from "./lib/graph-enums" 6 | export * from "./lib/graph-interaction" 7 | export * from "./lib/node-label" 8 | export * from "./lib/setup-fps-monitor" 9 | export * from "./lib/types" 10 | -------------------------------------------------------------------------------- /packages/graph/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import { Example } from "./components/Example" 4 | import "./index.css" 5 | 6 | ReactDOM.render(, document.getElementById(`root`)) 7 | -------------------------------------------------------------------------------- /packages/graph/src/lib/common-graph-util.ts: -------------------------------------------------------------------------------- 1 | import { Rectangle } from "pixi.js" 2 | import { GraphScales } from "./graph-enums" 3 | import { WithCoords, Node, ZoomLevels } from "./types" 4 | 5 | export function isNodeInsideBonds( 6 | node: WithCoords, 7 | bounds: Rectangle 8 | ): boolean { 9 | // bounds.x: The X coordinate of the upper-left corner of the rectangle 10 | // bounds.y: The Y coordinate of the upper-left corner of the rectangle 11 | /** 12 | * x,y------------------------- 13 | * | | 14 | * | | 15 | * | | 16 | * ---------------------------- <-- (x + width, y - height) 17 | */ 18 | return new Rectangle( 19 | bounds.x, 20 | bounds.y, 21 | bounds.width, 22 | bounds.height 23 | ).contains(node.x, node.y) 24 | } 25 | 26 | /** 27 | * 28 | * @param cc node.cc (children count) 29 | * @returns scaled number bigger than cc 30 | */ 31 | export function scaleByCC(cc: number): number { 32 | const cappedAt40 = Math.min(cc, 40) 33 | const numerator = Math.log(cappedAt40) 34 | const denominator = Math.log(6) 35 | return 1 + numerator / denominator 36 | } 37 | 38 | /** 39 | * Matches the current scale with appropriate minimum children count 40 | * Used to calculate which labels must appear based on current scale. 41 | * i.e. if zoomed out too much, you should probably see labels of nodes with 42 | * large children count (`cc`). 43 | * @param scale decreases as user zooms out 44 | */ 45 | export function scaleToMinChildrenCount( 46 | scale: number, 47 | { 48 | small = GraphScales.MAX_ZOOM, 49 | medium = GraphScales.MID_ZOOM, 50 | large = GraphScales.MIN_ZOOM, 51 | }: ZoomLevels = { 52 | small: GraphScales.MAX_ZOOM, 53 | medium: GraphScales.MID_ZOOM, 54 | large: GraphScales.MIN_ZOOM, 55 | } 56 | ): number { 57 | // the order of the case statements matters. 58 | switch (true) { 59 | // invalid case 60 | case scale <= 0: { 61 | return -1 62 | } 63 | // don't show any texts 64 | case scale < small: 65 | return Infinity 66 | case scale < medium: 67 | // show text from cc = 10 and above 68 | return 20 69 | case scale < large: { 70 | // show text from nodes having cc above 20 71 | return 10 72 | } 73 | case scale >= large: { 74 | // show text from nodes having cc above 0 75 | return 0 76 | } 77 | default: { 78 | return -1 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/graph/src/lib/conditional-node-labels-renderer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import { Viewport } from "pixi-viewport" 3 | import { Container } from "pixi.js" 4 | import debounce from "lodash.debounce" 5 | import { 6 | CustomFontConfig, 7 | Link, 8 | LinkWithCoords, 9 | LinkWithPartialCoords, 10 | Node, 11 | NodeLabel, 12 | NotSmallestNextVisibilityInput, 13 | SmallestNextVisibilityInput, 14 | WithCoords, 15 | WithPartialCoords, 16 | ZoomLevels, 17 | } from "./types" 18 | import { GraphEvents, RENDER_ALL } from "./graph-enums" 19 | import { NodeLabelHelper } from "./node-label" 20 | import { KnowledgeGraphDb } from "./db" 21 | import Dexie, { PromiseExtended } from "dexie" 22 | import to from "await-to-js" 23 | import { scaleToMinChildrenCount } from "./common-graph-util" 24 | import { GraphEventEmitter } from "./graphEvents" 25 | 26 | /** 27 | * Node labels renderer with Hierarchical Level of Detail (HLoD) and culling (only rendering what is currently seen by the camera) 28 | * 29 | * When `moved-end` event fires, these things can happen: 30 | * 1. Labels for 'big' nodes (with large children count) may be invisible if zoomed out very much 31 | * 2. Labels for 'small' nodes (with small children count) may be invisible if zoomed out a bit 32 | * 3. Labels for 'small' nodes (with small children count) may be visible if zoomed in very much 33 | * 4. Labels for 'big' nodes (with large children count) may be visible if zoomed in a bit 34 | * 5. New labels appear if the zoom level does not change and location (x,y) of camera changes 35 | * 6. Old labels disappear in the previous camera location if the zoom level does not change and location (x,y) of camera changes 36 | * 7. Some existing labels stay visible if the zoom level does not change very much and new camera location includes some part of the previous camera location 37 | * 38 | * The strategy to meet this condition: 39 | * 40 | * Every time moved-end event happens, 41 | * 1. See if any of the labels need to appear 42 | * 2. See if any of the labels need to stay visible 43 | * 3. See if any of the labels need to disappear 44 | */ 45 | export class ConditionalNodeLabelsRenderer< 46 | N extends WithCoords, 47 | L extends LinkWithCoords 48 | > { 49 | /** 50 | * Viewport of the application. 51 | */ 52 | private viewport: Viewport 53 | /** 54 | * Container that stores all labels. 55 | */ 56 | private nodeLabelsContainer = new Container() 57 | /** 58 | * This is always defined!! 59 | * The typing is just because of how TS works 60 | */ 61 | private db: KnowledgeGraphDb | null = null 62 | private visibleLabelsMap: Record> = {} 63 | // only used for events inside this class 64 | private eventTarget = new EventTarget() 65 | // used for events outside this class 66 | private graphEventEmitter: GraphEventEmitter 67 | private initComplete = false 68 | private zoomLevels?: ZoomLevels 69 | 70 | constructor( 71 | viewport: Viewport, 72 | nodes: WithCoords[], 73 | links: LinkWithCoords[], 74 | graphEventEmitter: GraphEventEmitter, 75 | /** 76 | * Optional db instantiated from outside of the class 77 | */ 78 | db?: KnowledgeGraphDb, 79 | /** 80 | * Custom zoom levels for conditionally rendering labels at different zoom levels 81 | */ 82 | zoomLevels?: ZoomLevels 83 | ) { 84 | this.viewport = viewport 85 | this.graphEventEmitter = graphEventEmitter 86 | this.nodeLabelsContainer.interactive = false 87 | this.nodeLabelsContainer.interactiveChildren = false 88 | this.viewport.addChild(this.nodeLabelsContainer) 89 | this.db = db ?? new KnowledgeGraphDb() 90 | this.initDb(nodes, links) 91 | this.initMovedEndListener() 92 | this.zoomLevels = zoomLevels 93 | } 94 | 95 | public onError(cb: VoidFunction) { 96 | this.eventTarget.addEventListener(GraphEvents.ERROR, cb) 97 | } 98 | 99 | /** 100 | * IndexedDB takes some time to be initialized 101 | */ 102 | public onInitComplete(cb: (...params: any[]) => any) { 103 | this.eventTarget.addEventListener( 104 | GraphEvents.FINISH_DB, 105 | () => { 106 | this.initComplete = true 107 | cb() 108 | }, 109 | { 110 | once: true, 111 | } 112 | ) 113 | } 114 | 115 | public getNodeLabelsContainer(): Container { 116 | return this.nodeLabelsContainer 117 | } 118 | 119 | /** 120 | * Since the indexedDB query returns plain arrays containing primary keys, 121 | * we need to turn some of them into `Set` for operations with better time complexities 122 | */ 123 | private optimizeNextLabelVisibilityInputs({ 124 | nodeIdsWithinXRange, 125 | nodeIdsWithinYRange, 126 | nodeIdsWithinCCRange, 127 | }: { 128 | nodeIdsWithinXRange: Node[`id`][] 129 | nodeIdsWithinYRange: Node[`id`][] 130 | nodeIdsWithinCCRange: Node[`id`][] | typeof RENDER_ALL 131 | }): { 132 | smallest: SmallestNextVisibilityInput 133 | set0: NotSmallestNextVisibilityInput 134 | set1: NotSmallestNextVisibilityInput 135 | } { 136 | const xRange = { 137 | data: nodeIdsWithinXRange, 138 | name: `x`, 139 | } 140 | const yRange = { 141 | data: nodeIdsWithinYRange, 142 | name: `y`, 143 | } 144 | const ccRange = { 145 | data: nodeIdsWithinCCRange, 146 | name: `cc`, 147 | } 148 | const dataAscOrderedByLength = [ 149 | { 150 | range: xRange, 151 | array: nodeIdsWithinXRange, 152 | }, 153 | { 154 | range: yRange, 155 | array: nodeIdsWithinYRange, 156 | }, 157 | { 158 | range: ccRange, 159 | array: nodeIdsWithinCCRange, 160 | }, 161 | ].sort( 162 | ({ range: { data: data0 } }, { range: { data: data1 } }) => 163 | data0.length - data1.length 164 | ) 165 | const optimizedInputs = dataAscOrderedByLength.map( 166 | ({ range: { data, name }, array }, i) => { 167 | const renderAll = data === `RENDER_ALL` 168 | return { 169 | rank: i, 170 | data: i === 0 ? (renderAll ? [] : data) : new Set(data), 171 | name, 172 | renderAll, 173 | array, 174 | } 175 | } 176 | ) 177 | 178 | return { 179 | smallest: optimizedInputs[0] as SmallestNextVisibilityInput, 180 | set0: optimizedInputs[1] as NotSmallestNextVisibilityInput, 181 | set1: optimizedInputs[2] as NotSmallestNextVisibilityInput, 182 | } 183 | } 184 | 185 | /** 186 | * Categorizes labels (nodes) into 187 | * 1. Now visible labels: any visible labels in the current screen. 188 | * 2. Now appearing labels: labels that did not exist but now must appear in the current screen 189 | * 3. Now disappearing labels: labels that existed in the screen but now must disappear 190 | */ 191 | private processPreviousAndNextLabels({ 192 | smallest, 193 | set0, 194 | set1, 195 | visibleNodesSet, 196 | }: 197 | | ReturnType< 198 | ConditionalNodeLabelsRenderer[`optimizeNextLabelVisibilityInputs`] 199 | > & { 200 | visibleNodesSet: Set 201 | }): { 202 | nowVisibleNodeIds: Node[`id`][] 203 | nowAppearingNodeIds: Node[`id`][] 204 | nowDisappearingNodes: NodeLabel[] 205 | } { 206 | const nowVisibleNodeIds: Node[`id`][] = [] 207 | const nowAppearingNodeIds: Node[`id`][] = [] 208 | const nowDisappearingNodes: NodeLabel[] = [] 209 | 210 | if (smallest.renderAll) { 211 | // set0 is the second smallest array 212 | for (const nodeId of set0.array) { 213 | if (set1.data.has(nodeId)) { 214 | nowVisibleNodeIds.push(nodeId) 215 | if (!visibleNodesSet.has(nodeId)) { 216 | nowAppearingNodeIds.push(nodeId) 217 | } 218 | } 219 | } 220 | } else { 221 | for (const nodeId of smallest.data) { 222 | if (set0.data.has(nodeId) && set1.data.has(nodeId)) { 223 | nowVisibleNodeIds.push(nodeId) 224 | if (!visibleNodesSet.has(nodeId)) { 225 | nowAppearingNodeIds.push(nodeId) 226 | } 227 | } 228 | } 229 | } 230 | 231 | return { 232 | nowVisibleNodeIds, 233 | nowAppearingNodeIds, 234 | nowDisappearingNodes, 235 | } 236 | } 237 | 238 | private async getVisibleNodesSet() { 239 | const pksPromise = this.db!.visibleNodes.toCollection().primaryKeys() 240 | const pks = await pksPromise 241 | 242 | return new Set(pks) 243 | } 244 | 245 | private async deleteDisappearingLabels(visibleNodesSet: Set) { 246 | const nowDisappearingNodes = [] 247 | const renderLabelsWithCCAboveOrEqual = scaleToMinChildrenCount( 248 | this.viewport.scale.x, 249 | this.zoomLevels 250 | ) 251 | for (const [nodeId, label] of Object.entries(this.visibleLabelsMap)) { 252 | const cc = label.getNodeData().cc ?? 0 253 | if (!visibleNodesSet.has(nodeId) || cc < renderLabelsWithCCAboveOrEqual) { 254 | nowDisappearingNodes.push(label) 255 | delete this.visibleLabelsMap[nodeId] 256 | } 257 | } 258 | this.nodeLabelsContainer.removeChild(...nowDisappearingNodes) 259 | } 260 | 261 | /** 262 | * @returns 263 | * a promise of transaction that will resolve to an array of 264 | * 1) the nodes that must appear now 265 | * 2) the nodes that must disappear now, 266 | * 267 | * and a method to `cancel` the transaction. 268 | */ 269 | private calculateNextLabelVisibility(visibleNodesSet: Set) { 270 | const renderLabelsWithCCAboveOrEqual = scaleToMinChildrenCount( 271 | this.viewport.scale.x, 272 | this.zoomLevels 273 | ) 274 | const hitArea = this.viewport.hitArea 275 | if (!hitArea) return null 276 | // @ts-ignore: bad ts definition 277 | const xLow: number = hitArea.left 278 | // @ts-ignore 279 | const yLow: number = hitArea.top 280 | // @ts-ignore 281 | const xHigh: number = hitArea.right 282 | // @ts-ignore 283 | const yHigh: number = hitArea.bottom 284 | 285 | const { transaction, cancel } = this.db!.cancellableTx( 286 | `rw`, 287 | [this.db!.nodes, this.db!.visibleNodes], 288 | async () => { 289 | const [nodeIdsWithinXRange, nodeIdsWithinYRange, nodeIdsWithinCCRange] = 290 | await Promise.all([ 291 | this.db!.nodes.where(`x`) 292 | .between(xLow, xHigh, true, true) 293 | .primaryKeys(), 294 | this.db!.nodes.where(`y`) 295 | .between(yLow, yHigh, true, true) 296 | .primaryKeys(), 297 | // there is no point of querying all primary keys if you get to render nodes in all cc's 298 | renderLabelsWithCCAboveOrEqual === 0 299 | ? RENDER_ALL 300 | : this.db!.nodes.where(`cc`) 301 | .between( 302 | renderLabelsWithCCAboveOrEqual, 303 | Dexie.maxKey, 304 | true, 305 | true 306 | ) 307 | .primaryKeys(), 308 | ]) 309 | const nextLabelVisibilityInputs = 310 | this.optimizeNextLabelVisibilityInputs({ 311 | nodeIdsWithinXRange, 312 | nodeIdsWithinYRange, 313 | nodeIdsWithinCCRange, 314 | }) 315 | const { nowAppearingNodeIds, nowDisappearingNodes, nowVisibleNodeIds } = 316 | this.processPreviousAndNextLabels({ 317 | ...nextLabelVisibilityInputs, 318 | visibleNodesSet, 319 | }) 320 | return Promise.all([ 321 | // Promise for the nodes that must appear now 322 | this.db!.nodes.bulkGet(nowAppearingNodeIds) as PromiseExtended< 323 | Node[] 324 | >, 325 | // Immediately resolved promise for the nodes that must disappear now 326 | nowDisappearingNodes, 327 | nowVisibleNodeIds, 328 | ]) 329 | } 330 | ) 331 | 332 | return { 333 | transaction, 334 | cancel, 335 | } 336 | } 337 | 338 | /** 339 | * make sure you still delete labels that go outside of the current screen 340 | * because you are moving around in a viewport 341 | */ 342 | private async deleteLabelsOnDragging() { 343 | const nowDisappearingNodes = [] 344 | const renderLabelsWithCCAboveOrEqual = scaleToMinChildrenCount( 345 | this.viewport.scale.x, 346 | this.zoomLevels 347 | ) 348 | const nowDisappearingNodeIds = [] 349 | for (const [nodeId, label] of Object.entries(this.visibleLabelsMap)) { 350 | const cc = label.getNodeData().cc ?? 0 351 | if ( 352 | !this.viewport.hitArea?.contains(label.x, label.y) || 353 | cc < renderLabelsWithCCAboveOrEqual 354 | ) { 355 | nowDisappearingNodes.push(label) 356 | nowDisappearingNodeIds.push(nodeId) 357 | delete this.visibleLabelsMap[nodeId] 358 | } 359 | } 360 | this.nodeLabelsContainer.removeChild(...nowDisappearingNodes) 361 | await this.db!.visibleNodes.bulkDelete(nowDisappearingNodeIds) 362 | } 363 | 364 | /** 365 | * moved-end callback of `this.viewport` 366 | */ 367 | private onMovedEnd = debounce(async () => { 368 | this.graphEventEmitter.emit(`startLabels`) 369 | const visibleNodesSet = await this.getVisibleNodesSet() 370 | const { transaction } = this.db!.cancellableTx( 371 | `rw`, 372 | [this.db!.nodes, this.db!.visibleNodes], 373 | async () => { 374 | const nextLabelVisibilityCalculation = 375 | this.calculateNextLabelVisibility(visibleNodesSet) 376 | if (!nextLabelVisibilityCalculation) return null 377 | const { transaction: nextLabelVisibilityTransaction } = 378 | nextLabelVisibilityCalculation 379 | const [nodesToAppear, nowDisappearingNodes, nowVisibleNodeIds] = 380 | await nextLabelVisibilityTransaction 381 | // Promise for updating currently visible nodes (returns nothing) 382 | const { transaction: visibleNodesTx } = this.db!.cancellableTx( 383 | `rw`, 384 | [this.db!.visibleNodes], 385 | async () => { 386 | // todo would using a plain Set() or object be faster than using a table? 387 | await this.db!.visibleNodes.clear() 388 | await this.db!.visibleNodes.bulkAdd( 389 | nowVisibleNodeIds.map((n) => ({ 390 | id: n, 391 | })) 392 | ) 393 | } 394 | ) 395 | await visibleNodesTx 396 | 397 | return { 398 | nowDisappearingNodes, 399 | nodesToAppear, 400 | } 401 | } 402 | ) 403 | const [[err, transactionResult]] = await Promise.all([ 404 | to(transaction), 405 | this.deleteLabelsOnDragging(), 406 | ]) 407 | 408 | if (!transactionResult || err) { 409 | this.graphEventEmitter.emit(`finishLabels`, []) 410 | return 411 | } 412 | const { nodesToAppear } = transactionResult 413 | this.deleteDisappearingLabels(visibleNodesSet) 414 | this.graphEventEmitter.emit(`finishLabels`, nodesToAppear as N[]) 415 | this.createBitmapTextsAsNodeLabels(nodesToAppear) 416 | }, 100) 417 | 418 | /** 419 | * Just dump everything into the db 420 | */ 421 | private async initDb(nodes: WithCoords[], links: LinkWithCoords[]) { 422 | await this.db!.delete().then(() => this.db!.open()) 423 | const n = nodes.map(({ cc, ...rest }) => ({ 424 | cc: cc ?? 0, 425 | ...rest, 426 | })) 427 | await this.db!.transaction( 428 | `rw`, 429 | [this.db!.nodes, this.db!.links], 430 | async () => { 431 | return Promise.all([ 432 | this.db!.links.bulkAdd(links), 433 | this.db!.nodes.bulkAdd(n), 434 | ]) 435 | } 436 | ) 437 | this.eventTarget.dispatchEvent(new Event(GraphEvents.FINISH_DB)) 438 | // to show the labels after DB init is complete 439 | this.onMovedEnd() 440 | } 441 | 442 | /** 443 | * moved-end event includes zoom, drag, ... everything. 444 | */ 445 | private async initMovedEndListener() { 446 | this.viewport.on(`moved-end`, () => { 447 | if (this.initComplete) { 448 | this.onMovedEnd() 449 | } 450 | }) 451 | } 452 | 453 | /** 454 | * Creates visual labels (texts) from the titles of nodes 455 | * @param nodes - nodes with titles 456 | */ 457 | private createBitmapTextsAsNodeLabels(nodes: WithPartialCoords[]) { 458 | const labels: NodeLabel[] = [] 459 | 460 | for (const node of nodes) { 461 | if ( 462 | node.title === undefined || 463 | node.x === undefined || 464 | node.y === undefined 465 | ) 466 | continue 467 | const text = NodeLabelHelper.createNodeLabel( 468 | node.title, 469 | node as WithCoords 470 | ) 471 | text.x = node.x 472 | text.y = node.y 473 | text.alpha = 0.7 474 | text.zIndex = 200 475 | labels.push(text) 476 | this.visibleLabelsMap[node.id] = text 477 | } 478 | if (labels.length > 0) this.nodeLabelsContainer.addChild(...labels) 479 | } 480 | } 481 | -------------------------------------------------------------------------------- /packages/graph/src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import Dexie, { 2 | IndexableType, 3 | Table, 4 | Transaction, 5 | TransactionMode, 6 | } from "dexie" 7 | import { Link, Node, WithCoords } from "./types" 8 | 9 | // https://dexie.org/docs/Typescript 10 | export class KnowledgeGraphDb extends Dexie { 11 | // Declare implicit table properties. 12 | // (just to inform Typescript. Instanciated by Dexie in stores() method) 13 | nodes!: Dexie.Table, string> // number = type of the primkey 14 | links!: Dexie.Table< 15 | { source: WithCoords; target: WithCoords }, 16 | number 17 | > 18 | // eslint-disable-next-line @typescript-eslint/ban-types 19 | visibleNodes!: Dexie.Table<{}, string> 20 | //...other tables goes here... 21 | 22 | constructor() { 23 | super(`kgDb`) 24 | this.version(1).stores({ 25 | nodes: `id, title, parentId, cc, type, x, y, [cc+x+y]`, 26 | links: `++id, source, target`, 27 | visibleNodes: `id`, 28 | }) 29 | } 30 | 31 | public cancellableTx( 32 | transactionMode: TransactionMode, 33 | includedTables: Table[], 34 | querierFunction: (...params: any[]) => Promise 35 | ) { 36 | let tx: Transaction | null = null 37 | let cancelled = false 38 | const transaction = this.transaction( 39 | transactionMode, 40 | includedTables, 41 | () => { 42 | if (cancelled) throw new Dexie.AbortError(`Query was cancelled`) 43 | tx = Dexie.currentTransaction 44 | return querierFunction() 45 | } 46 | ) 47 | return { 48 | transaction, 49 | cancel: () => { 50 | cancelled = true // In case transaction hasn't been started yet. 51 | if (tx) tx.abort() // If started, abort it. 52 | tx = null // Avoid calling abort twice. 53 | }, 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/graph/src/lib/db.worker.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphcentral/graph/62dee94abfef6a32e85d1b6c90c0a08efcdca0a2/packages/graph/src/lib/db.worker.ts -------------------------------------------------------------------------------- /packages/graph/src/lib/graph-enums.ts: -------------------------------------------------------------------------------- 1 | export const enum WorkerMessageType { 2 | START_GRAPH = `START_GRAPH`, 3 | UPDATE_NODES = `UPDATE_NODES`, 4 | UPDATE_LINKS = `UPDATE_LINKS`, 5 | /** 6 | * to be called only once when the graph is fully loaded 7 | * from the worker's side 8 | */ 9 | FINISH_GRAPH = `FINISH_GRAPH`, 10 | /** 11 | * to be only used when `runForceLayout: false`. 12 | * input nodes and links must be of the type produced by 13 | * d3-force. 14 | */ 15 | UPDATE_NODE_CHILDREN = `UPDATE_NODE_CHILDREN`, 16 | } 17 | 18 | export const enum GraphEvents { 19 | CLICK_NODE = `CLICK_NODE`, 20 | /** 21 | * Whenever a user moves around 22 | * labels are shown in that area. 23 | * To query the labels, some loading is needed 24 | * This event gets fired at times when user moves around 25 | * in the screen 26 | */ 27 | START_LABELS = `START_LABELS`, 28 | FINISH_LABELS = `FINISH_LABELS`, 29 | /** 30 | * Called when loading layout finishes 31 | * IndexedDB may not be loaded yet. 32 | * This event only gets called once 33 | */ 34 | START_LAYOUT = `START_LAYOUT`, 35 | FINISH_LAYOUT = `FINISH_LAYOUT`, 36 | /** 37 | * Called when loading the entire graph finishes 38 | * this means both layout and db have been initialized 39 | * This event only gets called once 40 | */ 41 | FINISH_GRAPH = `FINISH_GRAPH`, 42 | /** 43 | * Finish bulk insaerting initial data into the database 44 | * This event only gets called once 45 | */ 46 | FINISH_DB = `FINISH_DB`, 47 | ERROR = `ERROR`, 48 | } 49 | 50 | export enum GraphGraphics { 51 | CIRCLE_SIZE = 50, 52 | CIRCLE_SCALE_FACTOR = 1.5, 53 | } 54 | 55 | /** 56 | * decreases as user zooms out 57 | */ 58 | export enum GraphScales { 59 | MIN_ZOOM = 0.0440836883806951, 60 | MID_ZOOM = 0.013, 61 | MAX_ZOOM = 0.00949999848460085, 62 | } 63 | 64 | export enum GraphZIndex { 65 | NORMAL_CONTAINER_CIRCLE = 100, 66 | TEXT = 200, 67 | SELECTED_CIRCLE_OUTLINE_FEEDBACK = 300, 68 | HIGHLIGHTING_CIRCLE = 300, 69 | } 70 | 71 | export const RENDER_ALL = `RENDER_ALL` as const 72 | -------------------------------------------------------------------------------- /packages/graph/src/lib/graph-interaction.ts: -------------------------------------------------------------------------------- 1 | import { Viewport } from "pixi-viewport" 2 | import * as PIXI from "pixi.js" 3 | import { Container, ParticleContainer } from "pixi.js" 4 | import { GraphEventEmitter } from "./graphEvents" 5 | import { scaleByCC } from "./common-graph-util" 6 | import { GraphGraphics, GraphZIndex } from "./graph-enums" 7 | import { 8 | Node, 9 | Unpacked, 10 | WithPartialCoords, 11 | LinkWithPartialCoords, 12 | KnowledgeGraphOptions, 13 | WithCoords, 14 | LinkWithCoords, 15 | Link, 16 | } from "./types" 17 | 18 | export type InteractionState = { 19 | mousedownNodeId?: Node[`id`] 20 | prevHighlightedLinkIndices: { 21 | mousedown: number[] 22 | mouseover: number[] 23 | } 24 | } 25 | 26 | export type InteractionColors = { 27 | selected?: number 28 | children?: number 29 | } 30 | 31 | export class GraphInteraction< 32 | N extends WithCoords, 33 | L extends LinkWithCoords 34 | > { 35 | private options: KnowledgeGraphOptions = {} 36 | private selectedCircleOutlineFeedback: PIXI.Sprite 37 | private nodes: N[] = [] 38 | private app: PIXI.Application 39 | private links: L[] = [] 40 | private lineGraphicsContainer = new Container() 41 | private linkedNodesContainer = new ParticleContainer() 42 | private graphEventEmitter: GraphEventEmitter 43 | private interactionState: InteractionState = { 44 | prevHighlightedLinkIndices: { 45 | mousedown: [], 46 | mouseover: [], 47 | }, 48 | } 49 | private colors: Required = { 50 | selected: 0xfe8888, 51 | children: 0xfe8888, 52 | } 53 | private isGraphLayoutLoaded = false 54 | 55 | constructor({ 56 | options, 57 | app, 58 | viewport, 59 | lineGraphicsContainer, 60 | nodes, 61 | links, 62 | colors = { 63 | selected: 0xfe8888, 64 | children: 0xfe8888, 65 | }, 66 | graphEventEmitter, 67 | }: { 68 | options: KnowledgeGraphOptions 69 | app: PIXI.Application 70 | viewport: Viewport 71 | nodes: N[] 72 | links: L[] 73 | lineGraphicsContainer: Container 74 | graphEventEmitter: GraphEventEmitter 75 | colors?: Required 76 | }) { 77 | this.options = options 78 | this.app = app 79 | this.colors = colors 80 | this.graphEventEmitter = graphEventEmitter 81 | this.lineGraphicsContainer = lineGraphicsContainer 82 | this.selectedCircleOutlineFeedback = (() => { 83 | const circleGraphics = new PIXI.Graphics() 84 | .lineStyle(5, colors.selected, 1, 1, false) 85 | .beginFill(0, 0) 86 | .drawCircle(0, 0, GraphGraphics.CIRCLE_SIZE) 87 | .endFill() 88 | const circleTexture = app.renderer.generateTexture(circleGraphics) 89 | circleGraphics.destroy() 90 | return new PIXI.Sprite(circleTexture) 91 | })() 92 | 93 | this.selectedCircleOutlineFeedback.visible = false 94 | this.selectedCircleOutlineFeedback.renderable = false 95 | this.selectedCircleOutlineFeedback.zIndex = 300 96 | viewport.addChild(this.selectedCircleOutlineFeedback) 97 | viewport.addChild(this.linkedNodesContainer) 98 | 99 | this.nodes = nodes 100 | this.links = links 101 | this.graphEventEmitter.on( 102 | `finishLayout`, 103 | () => (this.isGraphLayoutLoaded = true) 104 | ) 105 | } 106 | 107 | public updateNodesAndLinks({ nodes, links }: { nodes?: N[]; links?: L[] }) { 108 | if (nodes) this.nodes = nodes 109 | if (links) this.links = links 110 | } 111 | 112 | private turnOffHighlightInPreviousLinks( 113 | event: keyof InteractionState[`prevHighlightedLinkIndices`] 114 | ) { 115 | this.interactionState.prevHighlightedLinkIndices[event].forEach( 116 | (prevLinkIndex) => { 117 | const prevLinegraphics = this.lineGraphicsContainer.children[ 118 | prevLinkIndex 119 | ] as PIXI.Graphics 120 | prevLinegraphics.tint = 0xffffff 121 | } 122 | ) 123 | this.interactionState.prevHighlightedLinkIndices[event] = [] 124 | } 125 | 126 | private turnOnHighlightInCurrentLinks( 127 | event: keyof InteractionState[`prevHighlightedLinkIndices`], 128 | node: N 129 | ) { 130 | ;[node.children, node.parents] 131 | .map((each) => each ?? []) 132 | .forEach((each) => { 133 | each.forEach((indices: Unpacked>) => { 134 | this.interactionState.prevHighlightedLinkIndices[event].push( 135 | indices.link 136 | ) 137 | const linkLineGraphics = this.lineGraphicsContainer.children[ 138 | indices.link 139 | ] as PIXI.Graphics 140 | linkLineGraphics.tint = this.colors.selected 141 | }) 142 | }) 143 | } 144 | 145 | private showSelectedCircleOutlineFeedback( 146 | normalContainerCircle: PIXI.Sprite 147 | ) { 148 | this.selectedCircleOutlineFeedback.scale.set( 149 | normalContainerCircle.scale.x, 150 | normalContainerCircle.scale.y 151 | ) 152 | this.selectedCircleOutlineFeedback.x = 153 | normalContainerCircle.x - normalContainerCircle.width / 2 154 | this.selectedCircleOutlineFeedback.y = 155 | normalContainerCircle.y - normalContainerCircle.height / 2 156 | this.selectedCircleOutlineFeedback.visible = true 157 | this.selectedCircleOutlineFeedback.renderable = true 158 | } 159 | 160 | private findLinkedNodes(node: N): N[] { 161 | return [node.children, node.parents] 162 | .map((each) => each ?? []) 163 | .flatMap((each) => 164 | each.map(({ node: nodeIndex }) => this.nodes[nodeIndex]) 165 | ) as N[] 166 | } 167 | 168 | private highlightLinkedNodes(linkedNodes: N[]) { 169 | this.linkedNodesContainer.removeChildren() 170 | const circleGraphics = new PIXI.Graphics() 171 | .lineStyle(10, this.colors.children, 1, 1, false) 172 | .beginFill(0, 0) 173 | .drawCircle(0, 0, GraphGraphics.CIRCLE_SIZE) 174 | .endFill() 175 | const circleTexture = this.app.renderer.generateTexture(circleGraphics) 176 | circleGraphics.destroy() 177 | const highlightingCircles: PIXI.Sprite[] = [] 178 | linkedNodes.forEach((targetNode) => { 179 | const highlightingCircle = new PIXI.Sprite(circleTexture) 180 | if ( 181 | !targetNode || 182 | targetNode.x === undefined || 183 | targetNode.y === undefined 184 | ) { 185 | return 186 | } 187 | highlightingCircle.x = targetNode.x 188 | highlightingCircle.y = targetNode.y 189 | highlightingCircle.zIndex = GraphZIndex.HIGHLIGHTING_CIRCLE 190 | if (targetNode.cc) { 191 | const scaleAmount = scaleByCC(targetNode.cc) 192 | highlightingCircle.scale.set(scaleAmount * 1.4, scaleAmount * 1.4) 193 | } else { 194 | const originalScaleWithoutCCAmplfier = highlightingCircle.scale.x 195 | highlightingCircle.scale.set( 196 | originalScaleWithoutCCAmplfier * 1.4, 197 | originalScaleWithoutCCAmplfier * 1.4 198 | ) 199 | } 200 | highlightingCircle.y -= highlightingCircle.height / 2 201 | highlightingCircle.x -= highlightingCircle.width / 2 202 | highlightingCircles.push(highlightingCircle) 203 | }) 204 | if (highlightingCircles.length > 0) 205 | this.linkedNodesContainer.addChild(...highlightingCircles) 206 | } 207 | 208 | public addEventListenersToCircle( 209 | normalContainerCircle: PIXI.Sprite, 210 | node: N 211 | ) { 212 | normalContainerCircle.off(`mousedown`) 213 | normalContainerCircle.off(`mouseover`) 214 | normalContainerCircle.off(`mouseout`) 215 | 216 | normalContainerCircle.interactive = true 217 | normalContainerCircle.on(`mousedown`, () => { 218 | if (!this.isGraphLayoutLoaded) return 219 | this.interactionState.mousedownNodeId = node.id 220 | this.turnOffHighlightInPreviousLinks(`mousedown`) 221 | this.turnOnHighlightInCurrentLinks(`mousedown`, node) 222 | const linkedNodes = this.findLinkedNodes(node) 223 | this.graphEventEmitter.emit(`clickNode`, { node, linkedNodes }) 224 | this.showSelectedCircleOutlineFeedback(normalContainerCircle) 225 | this.highlightLinkedNodes(linkedNodes) 226 | }) 227 | 228 | if (this.options?.optimization?.useMouseHoverEffect) { 229 | // buttonMode will make cursor: pointer when hovered 230 | normalContainerCircle.buttonMode = true 231 | normalContainerCircle.on(`mouseover`, () => { 232 | this.graphEventEmitter.emit(`mouseOverNode`, node) 233 | normalContainerCircle.scale.set( 234 | normalContainerCircle.scale.x * GraphGraphics.CIRCLE_SCALE_FACTOR, 235 | normalContainerCircle.scale.y * GraphGraphics.CIRCLE_SCALE_FACTOR 236 | ) 237 | if (this.interactionState.mousedownNodeId === node.id) return 238 | this.turnOnHighlightInCurrentLinks(`mouseover`, node) 239 | this.graphEventEmitter.emit(`mouseOverNode`, node) 240 | }) 241 | normalContainerCircle.on(`mouseout`, () => { 242 | this.graphEventEmitter.emit(`mouseOutNode`, node) 243 | normalContainerCircle.scale.set( 244 | normalContainerCircle.scale.x / GraphGraphics.CIRCLE_SCALE_FACTOR, 245 | normalContainerCircle.scale.y / GraphGraphics.CIRCLE_SCALE_FACTOR 246 | ) 247 | this.turnOffHighlightInPreviousLinks(`mouseover`) 248 | if (this.interactionState.mousedownNodeId === node.id) { 249 | // we are using the same line graphics, 250 | // so if mouseout happens from the clicked node, it's possible that 251 | // lines pointing to the clicked node turn white again 252 | // this code prevents that 253 | this.turnOnHighlightInCurrentLinks(`mousedown`, node) 254 | return 255 | } 256 | }) 257 | } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /packages/graph/src/lib/graph.worker.ts: -------------------------------------------------------------------------------- 1 | import { 2 | forceSimulation, 3 | forceLink, 4 | forceCenter, 5 | forceRadial, 6 | forceManyBody, 7 | } from "d3-force" 8 | // @ts-ignore 9 | import { forceManyBodyReuse } from "d3-force-reuse" 10 | import { Link, Node, WithIndex } from "./types" 11 | import { WorkerMessageType } from "./graph-enums" 12 | 13 | function updateNodeChildren( 14 | links: WithIndex<{ source: WithIndex; target: WithIndex }>[], 15 | nodes: WithIndex[] 16 | ) { 17 | for (const l of links) { 18 | const parent = nodes[l.target.index] 19 | const child = nodes[l.source.index] 20 | if (parent && child) { 21 | if (!(`children` in parent)) { 22 | parent.children = [] 23 | } 24 | if (!(`parents` in child)) { 25 | child.parents = [] 26 | } 27 | parent.children!.push({ node: l.source.index, link: l.index }) 28 | child.parents!.push({ node: l.target.index, link: l.index }) 29 | } 30 | } 31 | } 32 | 33 | self.onmessage = (msg) => { 34 | switch (msg.data.type) { 35 | case WorkerMessageType.UPDATE_NODE_CHILDREN: { 36 | const { nodes, links } = msg.data 37 | updateNodeChildren(links, nodes) 38 | self.postMessage({ 39 | links, 40 | nodes, 41 | type: WorkerMessageType.UPDATE_NODE_CHILDREN, 42 | }) 43 | self.postMessage({ 44 | type: WorkerMessageType.FINISH_GRAPH, 45 | }) 46 | break 47 | } 48 | case WorkerMessageType.START_GRAPH: { 49 | const { nodes, links } = msg.data 50 | const t0 = performance.now() 51 | const forceLinks = forceLink(links) 52 | .id( 53 | (node) => 54 | // @ts-ignore 55 | node.id 56 | ) 57 | .distance(2000) 58 | const simulation = forceSimulation(nodes) 59 | .force(`link`, forceLinks) 60 | .force(`charge`, forceManyBody().strength(-40_000)) 61 | .force(`center`, forceCenter()) 62 | .force(`dagRadial`, forceRadial(1)) 63 | .stop() 64 | const LAST_ITERATION = 10 65 | for (let i = 0; i < LAST_ITERATION; ++i) { 66 | simulation.tick(5) 67 | if (i === LAST_ITERATION - 1) { 68 | updateNodeChildren(links, nodes) 69 | self.postMessage({ 70 | nodes: nodes, 71 | type: WorkerMessageType.UPDATE_NODES, 72 | }) 73 | self.postMessage({ 74 | // links are modified by d3-force and will contain x and y coordinates in source and target 75 | links, 76 | type: WorkerMessageType.UPDATE_LINKS, 77 | }) 78 | self.postMessage({ 79 | type: WorkerMessageType.FINISH_GRAPH, 80 | }) 81 | } else { 82 | self.postMessage({ 83 | nodes: simulation.nodes(), 84 | type: WorkerMessageType.UPDATE_NODES, 85 | }) 86 | } 87 | } 88 | break 89 | } 90 | } 91 | } 92 | 93 | export default `` 94 | -------------------------------------------------------------------------------- /packages/graph/src/lib/graphEvents.ts: -------------------------------------------------------------------------------- 1 | import { EventKey, EventEmitter as EE } from "ee-ts" 2 | import { Link, LinkWithCoords, Node, WithCoords } from "./types" 3 | 4 | interface GraphEventCallbacks { 5 | clickNode({ 6 | node, 7 | linkedNodes, 8 | }: { 9 | node: WithCoords 10 | linkedNodes: WithCoords[] 11 | }): void 12 | mouseOverNode(node: WithCoords): void 13 | mouseOutNode(node: WithCoords): void 14 | startLabels: VoidFunction 15 | startLayout: VoidFunction 16 | finishDb: VoidFunction 17 | finishGraph(nodesAndLinks: { nodes: WithCoords[]; links: L[] }): void 18 | finishLabels(nodes: WithCoords[]): void 19 | finishLayout(nodesAndLinks: { nodes: WithCoords[]; links: L[] }): void 20 | error(e: Error): void 21 | } 22 | 23 | export type GraphEvents = EventKey 24 | 25 | export class GraphEventEmitter< 26 | N extends WithCoords = WithCoords, 27 | L extends LinkWithCoords = LinkWithCoords 28 | > extends EE> {} 29 | -------------------------------------------------------------------------------- /packages/graph/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import { Viewport } from "pixi-viewport" 2 | import ColorHash from "color-hash" 3 | import { setupFpsMonitor } from "./setup-fps-monitor" 4 | import { GraphGraphics, WorkerMessageType } from "./graph-enums" 5 | import { 6 | WithPartialCoords, 7 | LinkWithPartialCoords, 8 | WithCoords, 9 | Node, 10 | LinkWithCoords, 11 | KnowledgeGraphOptions, 12 | ZoomLevels, 13 | } from "./types" 14 | import { scaleByCC, scaleToMinChildrenCount } from "./common-graph-util" 15 | import { ConditionalNodeLabelsRenderer } from "./conditional-node-labels-renderer" 16 | import debounce from "lodash.debounce" 17 | import { KnowledgeGraphDb } from "./db" 18 | import { GraphInteraction } from "./graph-interaction" 19 | import { WebfontLoaderPlugin } from "pixi-webfont-loader" 20 | import { NodeLabelHelper } from "./node-label" 21 | import { Cull } from "@pixi-essentials/cull" 22 | import { 23 | Container, 24 | ParticleContainer, 25 | Application, 26 | Loader, 27 | Ticker, 28 | Graphics, 29 | RenderTexture, 30 | Sprite, 31 | Point, 32 | } from "pixi.js" 33 | import Worker from "./graph.worker" 34 | import { GraphEventEmitter } from "./graphEvents" 35 | 36 | export class KnowledgeGraph< 37 | N extends WithPartialCoords = WithPartialCoords, 38 | L extends LinkWithPartialCoords = LinkWithPartialCoords 39 | > { 40 | private nodes: N[] 41 | private links: L[] 42 | private app: Application 43 | private viewport: Viewport 44 | // @ts-ignore 45 | private graphWorker: Worker = new Worker() 46 | private conditionalNodeLabelsRenderer: ConditionalNodeLabelsRenderer< 47 | WithCoords, 48 | LinkWithCoords 49 | > | null = null 50 | private lineGraphicsContainer = new Container() 51 | private circleNodesContainer: ParticleContainer | Container = new Container() 52 | private circleNodesShadowContainer: Container | null = null 53 | /** 54 | * whether all the necessary steps for a fully functional, interactive graph 55 | * have been completed 56 | */ 57 | private culler = new Cull() 58 | private options: KnowledgeGraphOptions | undefined = {} 59 | private db: KnowledgeGraphDb = new KnowledgeGraphDb() 60 | private interaction: GraphInteraction, LinkWithCoords> 61 | public graphEventEmitter = new GraphEventEmitter< 62 | WithCoords, 63 | LinkWithCoords 64 | >() 65 | public isLoaded: Readonly = false 66 | private zoomLevels?: ZoomLevels 67 | 68 | constructor({ 69 | nodes, 70 | links, 71 | canvasElement, 72 | options = { 73 | optimization: { 74 | showEdgesOnCloseZoomOnly: true, 75 | }, 76 | }, 77 | }: { 78 | nodes: N[] 79 | links: L[] 80 | /** 81 | * if you want to access it later, use this.app. to do sos 82 | */ 83 | canvasElement: HTMLCanvasElement 84 | options?: KnowledgeGraphOptions 85 | }) { 86 | if (options.graph?.customFont) { 87 | Loader.registerPlugin(WebfontLoaderPlugin) 88 | Loader.shared 89 | .add({ 90 | name: `custom font`, 91 | url: options.graph?.customFont.url, 92 | }) 93 | .load() 94 | } 95 | Ticker.shared.maxFPS = this.options?.optimization?.maxTargetFPS ?? 60 96 | this.nodes = nodes 97 | this.links = links 98 | this.app = new Application({ 99 | backgroundColor: 0x131313, 100 | resizeTo: window, 101 | view: canvasElement, 102 | antialias: true, 103 | }) 104 | this.options = options 105 | 106 | this.viewport = new Viewport({ 107 | screenWidth: window.innerWidth, 108 | screenHeight: window.innerHeight, 109 | interaction: this.app.renderer.plugins[`interaction`], 110 | passiveWheel: true, 111 | }) 112 | window.addEventListener(`resize`, () => { 113 | this.viewport.resize(window.innerWidth, window.innerHeight) 114 | }) 115 | this.viewport.sortableChildren = true 116 | this.viewport.drag().pinch().wheel().decelerate() 117 | this.app.stage.addChild(this.viewport) 118 | 119 | this.viewport.addChild(this.lineGraphicsContainer) 120 | this.lineGraphicsContainer.interactiveChildren = false 121 | this.lineGraphicsContainer.interactive = false 122 | 123 | this.interaction = new GraphInteraction, LinkWithCoords>({ 124 | options, 125 | app: this.app, 126 | viewport: this.viewport, 127 | lineGraphicsContainer: this.lineGraphicsContainer, 128 | nodes: nodes as WithCoords[], 129 | links: links as LinkWithCoords[], 130 | graphEventEmitter: this.graphEventEmitter, 131 | }) 132 | 133 | if (this.options?.optimization?.useParticleContainer) 134 | this.circleNodesContainer = new ParticleContainer(100_000) 135 | this.viewport.addChild(this.circleNodesContainer) 136 | 137 | this.setupConditionalNodeLabelsRenderer() 138 | 139 | this.circleNodesShadowContainer = 140 | this.options?.optimization?.useShadowContainer && 141 | this.options?.optimization.useParticleContainer 142 | ? new Container() 143 | : null 144 | if (this.circleNodesShadowContainer) { 145 | this.circleNodesShadowContainer.visible = false 146 | this.circleNodesShadowContainer.renderable = false 147 | this.viewport.addChild(this.circleNodesShadowContainer) 148 | } 149 | 150 | this.culler.add(this.viewport) 151 | this.viewport.on( 152 | `moved-end`, 153 | debounce(() => { 154 | const minChildrenCount = scaleToMinChildrenCount(this.viewport.scale.x) 155 | if (this.options?.optimization?.showEdgesOnCloseZoomOnly) { 156 | if (minChildrenCount === Infinity) { 157 | this.lineGraphicsContainer.visible = false 158 | this.lineGraphicsContainer.renderable = false 159 | } else { 160 | this.lineGraphicsContainer.visible = true 161 | this.lineGraphicsContainer.renderable = true 162 | } 163 | } 164 | }, 100) 165 | ) 166 | this.app.renderer.on(`prerender`, () => { 167 | // Cull out all objects that don't intersect with the screen 168 | this.culler.cull(this.app.renderer.screen) 169 | }) 170 | } 171 | 172 | private async setupConditionalNodeLabelsRenderer() { 173 | await Promise.all([ 174 | new Promise((resolve) => { 175 | this.graphEventEmitter.one(`finishLayout`, () => { 176 | resolve(``) 177 | }) 178 | }), 179 | new Promise((resolve) => { 180 | if (!this.options?.graph?.customFont?.url) { 181 | resolve(``) 182 | } 183 | Loader.shared.onComplete.once(() => { 184 | resolve(Loader.shared.resources) 185 | }) 186 | }), 187 | ]) 188 | NodeLabelHelper.installMaybeCustomFont(this.options?.graph?.customFont) 189 | this.conditionalNodeLabelsRenderer = new ConditionalNodeLabelsRenderer< 190 | WithCoords, 191 | LinkWithCoords 192 | >( 193 | this.viewport, 194 | // by now it must have coordinates 195 | this.nodes as WithCoords[], 196 | this.links as LinkWithCoords[], 197 | this.graphEventEmitter, 198 | this.db, 199 | this.zoomLevels 200 | ) 201 | await new Promise((resolve) => { 202 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 203 | this.conditionalNodeLabelsRenderer!.onInitComplete(resolve) 204 | }) 205 | this.graphEventEmitter.emit(`finishDb`) 206 | this.isLoaded = true 207 | this.graphEventEmitter.emit(`finishGraph`, { 208 | nodes: this.nodes as WithCoords[], 209 | links: this.links as LinkWithCoords[], 210 | }) 211 | } 212 | 213 | private updateLinks({ links }: { links: L[] }) { 214 | this.links = links 215 | const lines: Graphics[] = [] 216 | for (const link of links) { 217 | const { x: sourceX, y: sourceY } = link.source 218 | const { x: targetX, y: targetY } = link.target 219 | if ( 220 | sourceX === undefined || 221 | sourceY === undefined || 222 | targetX === undefined || 223 | targetY === undefined 224 | ) 225 | continue 226 | const lineGraphics = new Graphics() 227 | .lineStyle(3, 0xffffff, 0.7, 1, false) 228 | .moveTo(sourceX, sourceY) 229 | .lineTo(targetX, targetY) 230 | .endFill() 231 | lines.push(lineGraphics) 232 | } 233 | if (lines.length > 0) { 234 | this.lineGraphicsContainer.addChild(...lines) 235 | } 236 | } 237 | 238 | private updateNodes({ 239 | circleTextureByParentId, 240 | particleContainerCircles, 241 | normalContainerCircles, 242 | isFirstTimeUpdatingNodes, 243 | nodes, 244 | }: { 245 | circleTextureByParentId: Record 246 | particleContainerCircles: Array 247 | normalContainerCircles: Array 248 | isFirstTimeUpdatingNodes: boolean 249 | nodes: WithPartialCoords[] 250 | }) { 251 | this.nodes = nodes 252 | const colorHash = new ColorHash() 253 | for (const [i, node] of nodes.entries()) { 254 | if (isFirstTimeUpdatingNodes) { 255 | node.cc = node.cc ?? 0 256 | const parentId = node.parentId 257 | if (parentId && !(parentId in circleTextureByParentId)) { 258 | const c = parseInt(colorHash.hex(parentId).replace(/^#/, ``), 16) 259 | const circleGraphics = new Graphics() 260 | .lineStyle(5, 0xffffff, 1, 1, false) 261 | .beginFill(c, 1) 262 | .drawCircle(0, 0, GraphGraphics.CIRCLE_SIZE) 263 | .endFill() 264 | const texture = this.app.renderer.generateTexture(circleGraphics) 265 | circleGraphics.destroy() 266 | circleTextureByParentId[parentId] = texture 267 | } 268 | 269 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 270 | const fallbackCircleTexture = circleTextureByParentId[`default`]! 271 | 272 | const circleTexture = 273 | parentId && !this.options?.optimization?.useParticleContainer 274 | ? circleTextureByParentId[parentId] 275 | : fallbackCircleTexture 276 | const normalContainerCircle = new Sprite( 277 | circleTexture ?? fallbackCircleTexture 278 | ) 279 | const particleContainerCircle = this.options?.optimization 280 | ?.useParticleContainer 281 | ? new Sprite(circleTexture ?? fallbackCircleTexture) 282 | : null 283 | normalContainerCircle.zIndex = 100 284 | 285 | if (node.x === undefined || node.y === undefined) return 286 | normalContainerCircle.x = node.x 287 | normalContainerCircle.y = node.y 288 | if (particleContainerCircle) { 289 | particleContainerCircle.x = node.x 290 | particleContainerCircle.y = node.y 291 | } 292 | // https://stackoverflow.com/questions/70302580/pixi-js-graphics-resize-normalContainerCircle-while-maintaining-center 293 | normalContainerCircle.pivot.x = normalContainerCircle.width / 2 294 | normalContainerCircle.pivot.y = normalContainerCircle.height / 2 295 | if (node.cc) { 296 | const scaleAmount = scaleByCC(node.cc) 297 | normalContainerCircle.scale.set(scaleAmount, scaleAmount) 298 | if (particleContainerCircle) { 299 | particleContainerCircle.scale.set(scaleAmount, scaleAmount) 300 | // pivot does not work for ParticleContainer, so manually adjust 301 | // for the pivot 302 | particleContainerCircle.y -= particleContainerCircle.height / 2 303 | particleContainerCircle.x -= particleContainerCircle.width / 2 304 | } 305 | } 306 | this.interaction.addEventListenersToCircle( 307 | normalContainerCircle, 308 | node as WithCoords 309 | ) 310 | if (particleContainerCircle) { 311 | particleContainerCircles.push(particleContainerCircle) 312 | } 313 | normalContainerCircles.push(normalContainerCircle) 314 | } else { 315 | if (node.x === undefined || node.y === undefined) return 316 | // normalContainerCircles order is preserved across force directed graph iterations 317 | const normalCircle = normalContainerCircles[i] 318 | const particleCircle = particleContainerCircles?.[i] 319 | if (normalCircle) { 320 | normalCircle.x = node.x 321 | normalCircle.y = node.y 322 | this.interaction.addEventListenersToCircle( 323 | normalCircle, 324 | node as WithCoords 325 | ) 326 | } 327 | if (particleCircle) { 328 | // pivot does not work for ParticleContainer, so manually adjust 329 | // for the pivot 330 | particleCircle.x = node.x - particleCircle.width / 2 331 | particleCircle.y = node.y - particleCircle.height / 2 332 | } 333 | } 334 | } 335 | } 336 | 337 | private addChildrenToCircleContainers({ 338 | particleContainerCircles, 339 | normalContainerCircles, 340 | }: { 341 | particleContainerCircles: Array 342 | normalContainerCircles: Array 343 | }) { 344 | if (this.options?.optimization?.useParticleContainer) { 345 | if (particleContainerCircles.length > 0) 346 | this.circleNodesContainer.addChild(...particleContainerCircles) 347 | if (this.options.optimization.useShadowContainer) { 348 | if (normalContainerCircles.length > 0) 349 | this.circleNodesShadowContainer?.addChild(...normalContainerCircles) 350 | } 351 | } else { 352 | this.circleNodesContainer.addChild(...normalContainerCircles) 353 | } 354 | } 355 | 356 | public createNetworkGraph() { 357 | const particleContainerCircles: Array = [] 358 | const normalContainerCircles: Array = [] 359 | const fallbackCircleGraphics = new Graphics() 360 | .lineStyle(5, 0xffffff, 1, 1, false) 361 | .beginFill(0xffffff, 1) 362 | .drawCircle(0, 0, GraphGraphics.CIRCLE_SIZE) 363 | .endFill() 364 | setupFpsMonitor(this.app) 365 | const circleTextureByParentId: Record = { 366 | default: this.app.renderer.generateTexture(fallbackCircleGraphics), 367 | } 368 | 369 | let isFirstTimeUpdatingNodes = true 370 | this.graphWorker.onmessage = (msg) => { 371 | switch (msg.data.type) { 372 | case WorkerMessageType.FINISH_GRAPH: { 373 | this.interaction.updateNodesAndLinks({ 374 | nodes: this.nodes as WithCoords[], 375 | links: this.links as LinkWithCoords[], 376 | }) 377 | this.graphEventEmitter.emit(`finishLayout`, { 378 | nodes: this.nodes as WithCoords[], 379 | links: this.links as LinkWithCoords[], 380 | }) 381 | break 382 | } 383 | case WorkerMessageType.UPDATE_NODE_CHILDREN: { 384 | this.updateNodes({ 385 | circleTextureByParentId, 386 | particleContainerCircles, 387 | normalContainerCircles, 388 | isFirstTimeUpdatingNodes: true, 389 | nodes: msg.data.nodes, 390 | }) 391 | this.addChildrenToCircleContainers({ 392 | particleContainerCircles, 393 | normalContainerCircles, 394 | }) 395 | this.updateLinks({ 396 | links: msg.data.links, 397 | }) 398 | break 399 | } 400 | case WorkerMessageType.UPDATE_NODES: { 401 | this.updateNodes({ 402 | circleTextureByParentId, 403 | particleContainerCircles, 404 | normalContainerCircles, 405 | isFirstTimeUpdatingNodes, 406 | nodes: msg.data.nodes, 407 | }) 408 | if (isFirstTimeUpdatingNodes) { 409 | const firstNode = msg.data.nodes[0] 410 | if (firstNode?.x !== undefined && firstNode.y !== undefined) { 411 | this.viewport.moveCenter(new Point(firstNode.x, firstNode.y)) 412 | this.viewport.fit( 413 | true, 414 | window.innerWidth * 5, 415 | window.innerHeight * 5 416 | ) 417 | } 418 | this.addChildrenToCircleContainers({ 419 | particleContainerCircles, 420 | normalContainerCircles, 421 | }) 422 | isFirstTimeUpdatingNodes = false 423 | } 424 | break 425 | } 426 | case WorkerMessageType.UPDATE_LINKS: { 427 | this.updateLinks({ 428 | links: msg.data.links, 429 | }) 430 | break 431 | } 432 | } 433 | } 434 | 435 | if (this.options?.graph?.runForceLayout === false) { 436 | this.graphWorker.postMessage({ 437 | type: WorkerMessageType.UPDATE_NODE_CHILDREN, 438 | nodes: this.nodes, 439 | links: this.links, 440 | }) 441 | return 442 | } 443 | 444 | this.graphEventEmitter.emit(`startLayout`) 445 | this.graphWorker.postMessage({ 446 | type: WorkerMessageType.START_GRAPH, 447 | nodes: this.nodes, 448 | links: this.links, 449 | }) 450 | } 451 | 452 | public moveTo(coords: Pick) { 453 | if (coords.x === undefined || coords.y === undefined) return 454 | this.viewport.moveCenter(new Point(coords.x, coords.y)) 455 | this.viewport.fit(true, window.innerWidth * 2.5, window.innerHeight * 2.5) 456 | } 457 | } 458 | -------------------------------------------------------------------------------- /packages/graph/src/lib/node-label.ts: -------------------------------------------------------------------------------- 1 | import * as PIXI from "pixi.js" 2 | import { scaleByCC } from "./common-graph-util" 3 | import { CustomFontConfig, Node, WithCoords } from "./types" 4 | 5 | /** 6 | * not included as a private function because 7 | * it cannot be called before `super` call in the class 8 | */ 9 | export class NodeLabelHelper { 10 | public static plainBitmapFontConfigs: [ 11 | Parameters[1], 12 | Parameters[2] 13 | ] = [ 14 | { 15 | fill: `#FFFFFF`, 16 | fontSize: 100, 17 | fontWeight: `bold`, 18 | }, 19 | { 20 | resolution: window.devicePixelRatio, 21 | chars: [ 22 | [`a`, `z`], 23 | [`A`, `Z`], 24 | [`0`, `9`], 25 | `~!@#$%^&*()_+-={}|:"<>?[]\\;',./ `, 26 | ], 27 | }, 28 | ] 29 | protected static customFontConfig?: 30 | | PIXI.TextStyle 31 | | Partial = {} 32 | protected static customFontOptions?: PIXI.IBitmapFontOptions = {} 33 | public static MAX_NODE_TITLE_LENGTH = 35 34 | public static CUSTOM_FONT_NAME: Readonly = `NKG_FONT` 35 | public static CJKRegex = 36 | /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f\u3131-\uD79D]/g 37 | /** 38 | * This needs to be called before creating any `NodeLabel` 39 | * @param customFont custom font info (i.e. font family) 40 | * @param customFontOptions custom font configurations 41 | * @returns BitmapFont 42 | */ 43 | public static installCustomFont( 44 | customFont?: PIXI.TextStyle | Partial, 45 | customFontOptions?: PIXI.IBitmapFontOptions 46 | ) { 47 | this.customFontConfig = customFont 48 | this.customFontOptions = customFontOptions 49 | return PIXI.BitmapFont.from( 50 | this.CUSTOM_FONT_NAME, 51 | customFont, 52 | customFontOptions 53 | ) 54 | } 55 | 56 | public static installDefaultFont() { 57 | return PIXI.BitmapFont.from( 58 | this.CUSTOM_FONT_NAME, 59 | ...this.plainBitmapFontConfigs 60 | ) 61 | } 62 | 63 | public static installMaybeCustomFont(customFontConfig?: CustomFontConfig) { 64 | if (customFontConfig) { 65 | this.installCustomFont(customFontConfig.config, customFontConfig.option) 66 | } else { 67 | this.installDefaultFont() 68 | } 69 | } 70 | 71 | public static containsCJK(text: string): boolean { 72 | return this.CJKRegex.test(text) 73 | } 74 | 75 | public static getMaybeShortenedTitle(text: string): string { 76 | return text.length > this.MAX_NODE_TITLE_LENGTH 77 | ? `${text.substring(0, this.MAX_NODE_TITLE_LENGTH)}...` 78 | : text 79 | } 80 | 81 | public static createNodeLabel( 82 | text: string, 83 | nodeData: WithCoords 84 | ) { 85 | const cc = nodeData.cc ?? 0 86 | if (this.containsCJK(text)) { 87 | return new VectorNodeLabel( 88 | text, 89 | nodeData, 90 | cc, 91 | this.customFontConfig, 92 | this.customFontOptions 93 | ) 94 | } else { 95 | return new BitmapNodeLabel(text, nodeData, cc) 96 | } 97 | } 98 | } 99 | 100 | abstract class NodeLabel { 101 | public abstract getNodeData(): WithCoords 102 | } 103 | 104 | export class BitmapNodeLabel 105 | extends PIXI.BitmapText 106 | implements NodeLabel 107 | { 108 | private nodeData: WithCoords 109 | constructor(text: string, nodeData: WithCoords, cc: number) { 110 | super(text, { 111 | fontSize: 100 * Math.max(1, scaleByCC(cc)), 112 | fontName: NodeLabelHelper.CUSTOM_FONT_NAME, 113 | }) 114 | this.nodeData = nodeData 115 | } 116 | public getNodeData(): WithCoords { 117 | return this.nodeData 118 | } 119 | } 120 | 121 | export class VectorNodeLabel extends PIXI.Text implements NodeLabel { 122 | private nodeData: WithCoords 123 | constructor( 124 | text: string, 125 | nodeData: WithCoords, 126 | cc: number, 127 | customFontConfig: NonNullable[`config`], 128 | customFontOptions: NonNullable[`option`] 129 | ) { 130 | super(text, { 131 | ...customFontConfig, 132 | ...customFontOptions, 133 | fontSize: 100 * Math.max(1, scaleByCC(cc)), 134 | // fontFamily: NodeLabelHelper.CUSTOM_FONT_NAME, 135 | }) 136 | this.nodeData = nodeData 137 | } 138 | 139 | public getNodeData(): WithCoords { 140 | return this.nodeData 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /packages/graph/src/lib/setup-fps-monitor.ts: -------------------------------------------------------------------------------- 1 | import * as PIXI from "pixi.js" 2 | 3 | export const setupFpsMonitor = (app: PIXI.Application) => { 4 | const fpsMonitor = document.createElement(`div`) 5 | fpsMonitor.setAttribute( 6 | `style`, 7 | `position: absolute; width: 10px; height: 5px; backgroundColor: black; color: white; right: 10px; top: 0;` 8 | ) 9 | setInterval(() => { 10 | fpsMonitor.textContent = app.ticker.FPS.toFixed(0) 11 | }, 100) 12 | 13 | document.body.append(fpsMonitor) 14 | } 15 | -------------------------------------------------------------------------------- /packages/graph/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import * as PIXI from "pixi.js" 2 | import { BitmapNodeLabel, VectorNodeLabel } from "./node-label" 3 | 4 | /** 5 | * A type used to represent a single Notion 'block' 6 | * or 'node' as we'd like to call it in this graph-related project 7 | */ 8 | export type Node = { 9 | title?: string 10 | id: string 11 | /** 12 | * parent node's id 13 | */ 14 | parentId?: Node[`id`] 15 | /** 16 | * nodeChildren count 17 | */ 18 | cc?: number 19 | type?: Type 20 | 21 | /** 22 | * array index of node and link in Node[]. 23 | * Becomes only available once webworker finishes the work 24 | */ 25 | children?: { node: number; link: number }[] 26 | /** 27 | * array index of node and link in Node[]. 28 | * Becomes only available once webworker finishes the work 29 | */ 30 | parents?: { node: number; link: number }[] 31 | } 32 | 33 | export interface Link { 34 | source: Node[`id`] 35 | target: Node[`id`] 36 | } 37 | 38 | export type Coords = { 39 | x: number 40 | y: number 41 | } 42 | 43 | export type StringCoords = { 44 | x: number 45 | y: number 46 | } 47 | 48 | export type WithCoords = T & Coords 49 | export type WithStringCoords = T & StringCoords 50 | export type WithPartialCoords = T & Partial 51 | export type LinkWithPartialCoords = { 52 | source: WithPartialCoords 53 | target: WithPartialCoords 54 | } 55 | export type LinkWithCoords = { 56 | source: WithCoords 57 | target: WithCoords 58 | } 59 | export type WithIndex = T & { 60 | index: number 61 | } 62 | 63 | export type SmallestNextVisibilityInput = { 64 | data: string[] 65 | name: string 66 | renderAll?: boolean 67 | array: string[] 68 | } 69 | 70 | export type NotSmallestNextVisibilityInput = { 71 | data: Set 72 | name: string 73 | renderAll?: boolean 74 | array: string[] 75 | } 76 | 77 | export type NextVisibilityInput = 78 | | SmallestNextVisibilityInput 79 | | NotSmallestNextVisibilityInput 80 | 81 | export type KnowledgeGraphOptions< 82 | N extends WithPartialCoords = WithPartialCoords, 83 | L extends LinkWithPartialCoords = LinkWithPartialCoords 84 | > = { 85 | optimization?: { 86 | /** 87 | * uses particle container for circle sprites. 88 | * this will show uniform color for all of the nodes when zoomed out. 89 | * 90 | * this will generally make rendering a bit faster but will disable 91 | * mouse hover interaction (only click possible) and set colors of all nodes 92 | * as white 93 | */ 94 | useParticleContainer?: boolean 95 | /** 96 | * does not show edges between nodes 97 | * when user zooms out beyond certain level 98 | */ 99 | showEdgesOnCloseZoomOnly?: boolean 100 | /** 101 | * set target FPS. between 1 and 60. 102 | */ 103 | maxTargetFPS?: number 104 | /** 105 | * uses another transparent container on the top of particle container. 106 | * this will allow interaction with the particle container. 107 | * 108 | * this will have no affect if `useParticleContainer === false`. 109 | */ 110 | useShadowContainer?: boolean 111 | /** 112 | * if set true, changes node (circle) style when hovered. 113 | * has no effect if `useParticleContainer === true` 114 | */ 115 | useMouseHoverEffect?: boolean 116 | } 117 | graph?: { 118 | /** 119 | * set this as false if you already have 120 | * a graph data with x and y coordinates of nodes 121 | * 122 | * if you need to compute it on the browser when the knowledge graph 123 | * initializes, set this as true 124 | */ 125 | runForceLayout?: boolean 126 | /** 127 | * pixi.js only supports showing basic alphanumeric characters 128 | * for BitmapFont. You will need to supply your own font 129 | * if the titles of the nodes are NOT english (for example, Chinese, Japanese or Korean, and so on..) 130 | * 131 | * For detailed options, see https://pixijs.download/dev/docs/PIXI.BitmapFont.html 132 | */ 133 | customFont?: { 134 | /** 135 | * example: https://fonts.googleapis.com/css2?family=Mouse+Memoirs&display=swap 136 | */ 137 | url: string 138 | /** 139 | * Options for the font, such as `fontFamily` or `fill`. You need to insert `fontFamily` as the name of the font. 140 | * For example, if the `url` is `https://fonts.googleapis.com/css2?family=Mouse+Memoirs&display=swap`, 141 | * `fontFamily` must be `"Mouse Memoirs"`. 142 | * 143 | * By default, the font's `fill` will be rendered in black and `fontFamily` will be set as none (falls back to the default font) if `fontFamily` and `fill` are not supplied, 144 | * even if `url` is present. 145 | */ 146 | config?: Parameters[1] 147 | option?: Parameters[2] 148 | } 149 | /** 150 | * Custom zoom levels used for the conditional rendering of labels at different zoom levels 151 | */ 152 | zoomLevels?: ZoomLevels 153 | } 154 | } 155 | 156 | export type NodeLabel = 157 | | BitmapNodeLabel 158 | | VectorNodeLabel 159 | 160 | export type CustomFontConfig = NonNullable< 161 | KnowledgeGraphOptions[`graph`] 162 | >[`customFont`] 163 | 164 | export type Unpacked = T extends (infer U)[] ? U : T 165 | 166 | /** 167 | * Zoom levels for conditional rendering of labels on a graph. 168 | * By default, these values are hard coded. Refer to GraphScales. 169 | * ``` 170 | * small = 0.0440836883806951, 171 | * medium = 0.013, 172 | * large = 0.00949999848460085, 173 | * ``` 174 | */ 175 | export interface ZoomLevels { 176 | small: number 177 | medium: number 178 | large: number 179 | } 180 | -------------------------------------------------------------------------------- /packages/graph/src/utilities/essentials.ts: -------------------------------------------------------------------------------- 1 | import { FC, memo } from "react" 2 | import { withErrorBoundary } from "../components/Util/WithErrorBoundary" 3 | import flow from "lodash.flow" 4 | 5 | export const enhance: ( 6 | Component: FC 7 | ) => ( 8 | Fallback?: FC 9 | ) => React.MemoExoticComponent<({ ...props }: Props) => JSX.Element> = flow( 10 | memo, 11 | withErrorBoundary 12 | ) 13 | 14 | export type TcResult = [null, Data] | [Throws] 15 | 16 | export async function tcAsync( 17 | promise: Promise 18 | ): Promise> { 19 | try { 20 | const response: T = await promise 21 | 22 | return [null, response] 23 | } catch (error) { 24 | return [error] as [Throws] 25 | } 26 | } 27 | 28 | export function tcSync< 29 | ArrType, 30 | Params extends Array, 31 | Returns, 32 | Throws = Error 33 | >( 34 | fn: (...params: Params) => Returns, 35 | ...deps: Params 36 | ): TcResult { 37 | try { 38 | const data: Returns = fn(...deps) 39 | 40 | return [null, data] 41 | } catch (e) { 42 | return [e] as [Throws] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/graph/tsconfig.d.ts.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["lib", "src/index.ts"], 3 | "compilerOptions": { 4 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 5 | 6 | /* Basic Options */ 7 | // "incremental": true, /* Enable incremental compilation */ 8 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 9 | "module": "ES2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 10 | "lib": ["DOM", "ESNext"], /* Specify library files to be included in the compilation. */ 11 | // "allowJs": true, /* Allow javascript files to be compiled. */ 12 | // "checkJs": true, /* Report errors in .js files. */ 13 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 14 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 15 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 16 | "sourceMap": true, /* Generates corresponding '.map' file. */ 17 | // "outFile": "./", /* Concatenate and emit output to single file. */ 18 | // "outDir": "./", /* Redirect output structure to the directory. */ 19 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 20 | // "composite": true, /* Enable project compilation */ 21 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 22 | // "removeComments": true, /* Do not emit comments to output. */ 23 | // "noEmit": true, /* Do not emit outputs. */ 24 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 25 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 26 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 27 | 28 | /* Strict Type-Checking Options */ 29 | "strict": true, /* Enable all strict type-checking options. */ 30 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 31 | "strictNullChecks": true, /* Enable strict null checks. */ 32 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 33 | "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 34 | "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 35 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 36 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 37 | 38 | /* Additional Checks */ 39 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 40 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 41 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 42 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 43 | "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 44 | "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 45 | 46 | /* Module Resolution Options */ 47 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 48 | "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 49 | "paths": { 50 | "src/*": [ 51 | "./src/*" 52 | ] 53 | }, 54 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 55 | // "typeRoots": [], /* List of folders to include type definitions from. */ 56 | // "types": [], /* Type declaration files to be included in compilation. */ 57 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 58 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 59 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 60 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 61 | 62 | /* Source Map Options */ 63 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 64 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 65 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 66 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 67 | 68 | /* Experimental Options */ 69 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 70 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 71 | 72 | /* Advanced Options */ 73 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 74 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 75 | "resolveJsonModule": true 76 | }, 77 | } 78 | -------------------------------------------------------------------------------- /packages/graph/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | // these options are overrides used only by ts-node 4 | // same as the --compilerOptions flag and the TS_NODE_COMPILER_OPTIONS environment variable 5 | "compilerOptions": { 6 | "module": "commonjs" 7 | } 8 | }, 9 | "compilerOptions": { 10 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 11 | 12 | /* Basic Options */ 13 | // "incremental": true, /* Enable incremental compilation */ 14 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 15 | "module": "ES2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 16 | "lib": ["DOM", "ESNext"], /* Specify library files to be included in the compilation. */ 17 | // "allowJs": true, /* Allow javascript files to be compiled. */ 18 | // "checkJs": true, /* Report errors in .js files. */ 19 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 20 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 21 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 22 | "sourceMap": true, /* Generates corresponding '.map' file. */ 23 | // "outFile": "./", /* Concatenate and emit output to single file. */ 24 | // "outDir": "./", /* Redirect output structure to the directory. */ 25 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 26 | // "composite": true, /* Enable project compilation */ 27 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 28 | // "removeComments": true, /* Do not emit comments to output. */ 29 | // "noEmit": true, /* Do not emit outputs. */ 30 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 31 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 32 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 33 | 34 | /* Strict Type-Checking Options */ 35 | "strict": true, /* Enable all strict type-checking options. */ 36 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 37 | "strictNullChecks": true, /* Enable strict null checks. */ 38 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 39 | "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 40 | "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 41 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 42 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 43 | 44 | /* Additional Checks */ 45 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 46 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 47 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 48 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 49 | "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 50 | "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 51 | 52 | /* Module Resolution Options */ 53 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 54 | "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 55 | "paths": { 56 | "src/*": [ 57 | "./src/*" 58 | ] 59 | }, 60 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 61 | // "typeRoots": [], /* List of folders to include type definitions from. */ 62 | // "types": [], /* Type declaration files to be included in compilation. */ 63 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 64 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 65 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 66 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 67 | 68 | /* Source Map Options */ 69 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 70 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 71 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 72 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 73 | 74 | /* Experimental Options */ 75 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 76 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 77 | 78 | /* Advanced Options */ 79 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 80 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 81 | "resolveJsonModule": true 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /packages/graph/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | }, 7 | "compilerOptions": { 8 | "target": "es6", 9 | "module": "es2022", 10 | "lib": ["DOM", "ESNext", "WebWorker"], 11 | "jsx": "react", 12 | "sourceMap": true, 13 | "downlevelIteration": true, 14 | "strict": true, 15 | "noImplicitAny": true, 16 | "strictNullChecks": true, 17 | "strictFunctionTypes": true, 18 | "strictBindCallApply": true, 19 | "strictPropertyInitialization": true, 20 | "noImplicitThis": true, 21 | "alwaysStrict": true, 22 | "noImplicitReturns": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedIndexedAccess": true, 25 | "noPropertyAccessFromIndexSignature": true, 26 | "moduleResolution": "node", 27 | "baseUrl": "./", 28 | "paths": { 29 | "src/*": [ 30 | "./src/*" 31 | ] 32 | }, 33 | "allowSyntheticDefaultImports": true, 34 | "esModuleInterop": true, 35 | "skipLibCheck": true, 36 | "forceConsistentCasingInFileNames": true, 37 | "resolveJsonModule": true 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/graph/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup" 2 | 3 | export default defineConfig({ 4 | entry: [`src/index.ts`], 5 | splitting: true, 6 | sourcemap: false, 7 | clean: true, 8 | format: [`esm`], 9 | }) 10 | -------------------------------------------------------------------------------- /packages/graph/webpack/README.md: -------------------------------------------------------------------------------- 1 | Because of limited support of WebWorker in Webpack 5, the version was downgraded to @^4. legacy-v5 contains v5 configs. -------------------------------------------------------------------------------- /packages/graph/webpack/legacy-v5/webpack.config.common.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import webpack from "webpack" 3 | import HtmlWebpackPlugin from "html-webpack-plugin" 4 | // @ts-ignore 5 | import PreloadWebpackPlugin from "@vue/preload-webpack-plugin" 6 | 7 | // const optimization: webpack.Configuration[`optimization`] = { 8 | // runtimeChunk: `multiple`, 9 | // splitChunks: { 10 | // chunks: `all`, 11 | // name: "shared", 12 | // cacheGroups: { 13 | // vendor: { 14 | // test: /[\\/]node_modules[\\/]/, 15 | // //@ts-ignore 16 | // name(module) { 17 | // // get the name. E.g. node_modules/packageName/not/this/part.js 18 | // // or node_modules/packageName 19 | // const packageName = module.context.match( 20 | // /[\\/]node_modules[\\/](.*?)([\\/]|$)/ 21 | // )[1] 22 | 23 | // // npm package names are URL-safe, but some servers don't like @ symbols 24 | // return `npm.${packageName.replace(`@`, ``)}` 25 | // }, 26 | // }, 27 | // }, 28 | // }, 29 | // } 30 | 31 | const createWorkerConfig: (workerPath: string) => webpack.Configuration = ( 32 | workerPath 33 | ) => ({ 34 | entry: workerPath, 35 | output: { 36 | filename: `[name].worker.js`, 37 | path: path.resolve(__dirname, `dist`), 38 | publicPath: `dist/`, 39 | globalObject: `this`, 40 | }, 41 | target: `webworker`, 42 | devtool: `source-map`, 43 | mode: `development`, 44 | resolve: { 45 | modules: [`src`, `node_modules`], 46 | extensions: [`.js`, `.ts`, `.tsx`], 47 | plugins: [], 48 | }, 49 | module: { 50 | rules: [ 51 | { 52 | test: /\.tsx?$/, 53 | loader: `ts-loader`, 54 | options: { 55 | transpileOnly: true, 56 | }, 57 | // exclude: /node_modules/, 58 | }, 59 | ], 60 | }, 61 | }) 62 | 63 | export const workerConfigs = [`./src/lib/graph.worker.ts`].map( 64 | createWorkerConfig 65 | ) 66 | 67 | export const commonConfig: webpack.Configuration = { 68 | entry: `./src/index.tsx`, 69 | // https://webpack.js.org/plugins/split-chunks-plugin/ 70 | optimization: { 71 | // https://stackoverflow.com/questions/58073626/uncaught-typeerror-cannot-read-property-call-of-undefined-at-webpack-requir 72 | sideEffects: false, // <----- in prod defaults to true if left blank 73 | splitChunks: { 74 | chunks: `all`, 75 | minSize: 500, 76 | // minRemainingSize: 0, 77 | minChunks: 1, 78 | maxAsyncRequests: 30, 79 | maxInitialRequests: 30, 80 | // enforceSizeThreshold: 50000, 81 | cacheGroups: { 82 | defaultVendors: { 83 | test: /[\\/]node_modules[\\/]/, 84 | priority: -10, 85 | reuseExistingChunk: true, 86 | }, 87 | default: { 88 | minChunks: 2, 89 | priority: -20, 90 | reuseExistingChunk: true, 91 | }, 92 | }, 93 | }, 94 | }, 95 | module: { 96 | rules: [ 97 | { 98 | test: /\.tsx?$/, 99 | use: `ts-loader`, 100 | exclude: /node_modules/, 101 | }, 102 | { 103 | test: /\.css?$/, 104 | use: [`style-loader`, `css-loader`], 105 | }, 106 | { 107 | test: /\.(fnt)$/i, 108 | type: `asset/resource`, 109 | generator: { 110 | filename: `static/[name].fnt`, 111 | }, 112 | }, 113 | ], 114 | }, 115 | resolve: { 116 | extensions: [`.tsx`, `.ts`, `.js`], 117 | // roots: [path.resolve(`.`)], 118 | // alias: { 119 | // src: path.resolve(__dirname, `.`), 120 | // }, 121 | }, 122 | output: { 123 | filename: `[chunkhash].[name].js`, 124 | path: path.resolve(__dirname, `dist`), 125 | }, 126 | plugins: [ 127 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 128 | // @ts-ignore 129 | new HtmlWebpackPlugin({ 130 | template: path.join(__dirname, `..`, `public`, `index.html`), 131 | }), 132 | new PreloadWebpackPlugin({}), 133 | ], 134 | } 135 | -------------------------------------------------------------------------------- /packages/graph/webpack/legacy-v5/webpack.config.dev.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import webpack from "webpack" 3 | // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/27570#issuecomment-437115227 4 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 5 | // @ts-ignore 6 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 7 | import * as webpackDevServer from "webpack-dev-server" 8 | import { commonConfig, workerConfigs } from "./webpack.config.common" 9 | 10 | const config: webpack.Configuration = { 11 | mode: `development`, 12 | devtool: `inline-source-map`, 13 | devServer: { 14 | static: path.join(__dirname, `dist`), 15 | // publicPath: path.resolve(`static`), 16 | compress: true, 17 | port: 8080, 18 | open: true, 19 | }, 20 | ...commonConfig, 21 | } 22 | 23 | export default [config, ...workerConfigs] 24 | -------------------------------------------------------------------------------- /packages/graph/webpack/legacy-v5/webpack.config.prod.ts: -------------------------------------------------------------------------------- 1 | import webpack from "webpack" 2 | import { commonConfig, workerConfigs } from "./webpack.config.common" 3 | 4 | const config: webpack.Configuration = { 5 | mode: `production`, 6 | ...commonConfig, 7 | } 8 | 9 | export default [config, ...workerConfigs] 10 | -------------------------------------------------------------------------------- /packages/graph/webpack/webpack.config.dev.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import webpack from "webpack" 3 | // @ts-ignore 4 | import HtmlWebpackPlugin from "html-webpack-plugin" 5 | // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/27570#issuecomment-437115227 6 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 | // @ts-ignore 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | import * as webpackDevServer from "webpack-dev-server" 10 | 11 | const config: webpack.Configuration = { 12 | entry: `./src/index.tsx`, 13 | // https://webpack.js.org/plugins/split-chunks-plugin/ 14 | optimization: { 15 | // https://stackoverflow.com/questions/58073626/uncaught-typeerror-cannot-read-property-call-of-undefined-at-webpack-requir 16 | sideEffects: false, // <----- in prod defaults to true if left blank 17 | splitChunks: { 18 | chunks: `all`, 19 | minSize: 500, 20 | // minRemainingSize: 0, 21 | minChunks: 1, 22 | maxAsyncRequests: 30, 23 | maxInitialRequests: 30, 24 | // enforceSizeThreshold: 50000, 25 | cacheGroups: { 26 | defaultVendors: { 27 | test: /[\\/]node_modules[\\/]/, 28 | priority: -10, 29 | reuseExistingChunk: true, 30 | }, 31 | default: { 32 | minChunks: 2, 33 | priority: -20, 34 | reuseExistingChunk: true, 35 | }, 36 | }, 37 | }, 38 | }, 39 | module: { 40 | rules: [ 41 | { 42 | test: /\.worker\.ts$/, 43 | loader: `worker-loader`, 44 | options: { 45 | // no-fallback in prod 46 | inline: `fallback`, 47 | }, 48 | }, 49 | { 50 | test: /\.tsx?$/, 51 | use: `ts-loader`, 52 | exclude: /node_modules/, 53 | }, 54 | { 55 | test: /\.css?$/, 56 | use: [`style-loader`, `css-loader`], 57 | }, 58 | ], 59 | }, 60 | resolve: { 61 | extensions: [`.tsx`, `.ts`, `.js`], 62 | }, 63 | output: { 64 | filename: `[chunkhash].[name].js`, 65 | path: path.resolve(__dirname, `dist`), 66 | }, 67 | plugins: [ 68 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 69 | // @ts-ignore 70 | new HtmlWebpackPlugin({ 71 | template: path.join(__dirname, `..`, `public`, `index.html`), 72 | }), 73 | ], 74 | mode: `development`, 75 | devtool: `inline-source-map`, 76 | devServer: { 77 | static: path.join(__dirname, `dist`), 78 | // publicPath: path.resolve(`static`), 79 | compress: true, 80 | port: 8080, 81 | open: true, 82 | }, 83 | } 84 | 85 | export default config 86 | -------------------------------------------------------------------------------- /packages/graph/webpack/webpack.config.lib.dev.ts: -------------------------------------------------------------------------------- 1 | import genLibConfig from "./webpack.lib.config.gen" 2 | 3 | export default genLibConfig(true) 4 | -------------------------------------------------------------------------------- /packages/graph/webpack/webpack.config.lib.prod.ts: -------------------------------------------------------------------------------- 1 | import genLibConfig from "./webpack.lib.config.gen" 2 | 3 | export default genLibConfig(false) 4 | -------------------------------------------------------------------------------- /packages/graph/webpack/webpack.lib.config.gen.ts: -------------------------------------------------------------------------------- 1 | import webpack from "webpack" 2 | import tsconfigRaw from "../tsconfig.lib.json" 3 | import BundleAnalyzerPlugin from "webpack-bundle-analyzer" 4 | 5 | function genLibConfig(isDevelopment: boolean): webpack.Configuration { 6 | return { 7 | entry: { 8 | main: `./src/index.ts`, 9 | }, 10 | optimization: { 11 | usedExports: true, 12 | }, 13 | mode: isDevelopment ? `development` : `production`, 14 | devtool: isDevelopment ? `source-map` : undefined, 15 | module: { 16 | rules: [ 17 | // Place this *before* the `ts-loader`. 18 | { 19 | test: /\.worker\.ts$/, 20 | loader: `worker-loader`, 21 | options: { 22 | // no-fallback in prod 23 | inline: `fallback`, 24 | }, 25 | }, 26 | { 27 | test: /\.tsx?$/, 28 | loader: `esbuild-loader`, 29 | options: { 30 | loader: `tsx`, 31 | target: `es2015`, 32 | tsconfigRaw, 33 | }, 34 | }, 35 | ], 36 | }, 37 | resolve: { 38 | extensions: [`.tsx`, `.ts`, `.js`], 39 | alias: { 40 | process: `process/browser`, 41 | }, 42 | }, 43 | output: { 44 | filename: (pathData) => { 45 | return pathData.chunk?.name === `main` 46 | ? `[name].js` 47 | : `[contenthash].[name].js` 48 | }, 49 | publicPath: `/assets/`, 50 | libraryTarget: `commonjs2`, 51 | globalObject: `this`, 52 | asyncChunks: true, 53 | }, 54 | plugins: [ 55 | new webpack.ProvidePlugin({ 56 | process: `process/browser`, 57 | }), 58 | ...(isDevelopment 59 | ? [new BundleAnalyzerPlugin.BundleAnalyzerPlugin()] 60 | : []), 61 | ], 62 | } 63 | } 64 | 65 | export default genLibConfig 66 | -------------------------------------------------------------------------------- /packages/graph/worker.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line quotes 2 | declare module "*.worker.ts" { 3 | // You need to change `Worker`, if you specified a different value for the `workerType` option 4 | class WebpackWorker extends Worker { 5 | constructor() 6 | } 7 | 8 | // Uncomment this if you set the `esModule` option to `false` 9 | // export = WebpackWorker; 10 | export default WebpackWorker 11 | } 12 | -------------------------------------------------------------------------------- /packages/test-data/README.md: -------------------------------------------------------------------------------- 1 | Run `npm run gen` to get some mock test data that can be used as an input to `@graphcentral/graph`. 2 | 3 | Example: 4 | 5 | ```bash 6 | npm run test-data:gen -- --children=5000 --parent=100 --genLayout=1 7 | ``` -------------------------------------------------------------------------------- /packages/test-data/force-graph-on-existing-data.mts: -------------------------------------------------------------------------------- 1 | import d3Force, { forceSimulation, forceCenter, forceLink, forceManyBody, forceRadial } from "d3-force" 2 | import data from "./data/notion-help-docs.json" assert {type: "json"}; 3 | import fs from "fs" 4 | 5 | const main = async () => { 6 | const forceLinks = forceLink(data.links) 7 | .id( 8 | (node) => 9 | // @ts-ignore 10 | node.id 11 | ) 12 | .distance(2000) 13 | // @ts-ignore 14 | const simulation = forceSimulation(data.nodes) 15 | .force(`link`, forceLinks) 16 | .force(`charge`, forceManyBody().strength(-40_000)) 17 | .force(`center`, forceCenter()) 18 | .force(`dagRadial`, forceRadial(1)) 19 | .stop() 20 | for (let i = 0; i < 10; ++i) { 21 | console.log(`${i * 5}th tick...`) 22 | simulation.tick(5) 23 | } 24 | 25 | data.nodes.forEach((node) => { 26 | // @ts-ignore 27 | delete node['vx'] 28 | // @ts-ignore 29 | delete node['vy'] 30 | }) 31 | data.links.forEach((link) => { 32 | // @ts-ignore 33 | delete link.source['vx'] 34 | // @ts-ignore 35 | delete link.source['vy'] 36 | // @ts-ignore 37 | delete link.source['vx'] 38 | // @ts-ignore 39 | delete link.source['vy'] 40 | }) 41 | console.log(`writing`) 42 | await new Promise((resolve, reject) => { 43 | fs.writeFile(`test0.json`, JSON.stringify(data), (err) => { 44 | if (err) reject(err) 45 | else resolve(``) 46 | }) 47 | }); 48 | console.log(`writing done`) 49 | process.exit(0) 50 | } 51 | 52 | main() -------------------------------------------------------------------------------- /packages/test-data/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-data", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "type": "module", 6 | "scripts": { 7 | "gen": "node --loader ts-node/esm test-data-gen.mts", 8 | "gen:existing": "node --loader ts-node/esm force-graph-on-existing-data.mts" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/graphcentral/graph.git" 13 | }, 14 | "author": "", 15 | "license": "ISC", 16 | "bugs": { 17 | "url": "https://github.com/graphcentral/graph/issues" 18 | }, 19 | "homepage": "https://github.com/graphcentral/graph#readme", 20 | "description": "### `this.unofficialNotionAPI.getPage()` - Returns the list of the page itself and recurisve parents. - The order of the list reflects the parent-child relationship: for example,", 21 | "dependencies": { 22 | "arg": "^5.0.2", 23 | "d3-force": "^3.0.0", 24 | "d3-force-reuse": "^1.0.1", 25 | "random-words": "^1.2.0" 26 | }, 27 | "devDependencies": { 28 | "ts-node": "^10.9.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/test-data/test-data-gen.mts: -------------------------------------------------------------------------------- 1 | import randomWords from "random-words" 2 | import fs from "fs" 3 | import path from 'path' 4 | import arg from 'arg' 5 | 6 | /** 7 | * This simple script creates a graph data for testing 8 | */ 9 | 10 | const args = arg({ 11 | '--help': Boolean, 12 | '--parent': Number, 13 | '--children': Number, 14 | '--maxCC': Number, 15 | '--genLayout': Number, 16 | // Aliases 17 | '-h': '--help', 18 | '-p': '--parent', 19 | '-c': '--children', 20 | '-mc': '--maxCC' 21 | }); 22 | 23 | const parsedArgs = { 24 | '--help': 0, 25 | '--children': 49500, 26 | '--parent': 500, 27 | '--maxCC': 25, 28 | '--genLayout': 0, 29 | }; 30 | type ParsedArgsKeys = keyof typeof parsedArgs 31 | type ParsedArgsVals = typeof parsedArgs[ParsedArgsKeys] 32 | 33 | for (const [argKey, argVal] of Object.entries(args)) { 34 | switch (argKey as ParsedArgsKeys) { 35 | case `--help`: 36 | console.log(`example: node --loader ts-node/esm test-data-gen.mts --children=49500 --parent=500 --maxCC=25 --genLayout=0`) 37 | process.exit(0) 38 | break 39 | case `--children`: 40 | case `--parent`: 41 | case `--maxCC`: 42 | case `--genLayout`: 43 | const typedArgKey = argKey as ParsedArgsKeys 44 | const typedArgVal = argVal as ParsedArgsVals 45 | if (argVal) parsedArgs[typedArgKey] = typedArgVal 46 | } 47 | } 48 | 49 | async function main() { 50 | console.log(`started...`) 51 | console.log(JSON.stringify(parsedArgs)) 52 | const CHILDREN_NODES_NUM = parsedArgs['--children'] 53 | const PARENT_NODES_NUM = parsedArgs['--parent'] 54 | const GENERATE_LAYOUT = Boolean(parsedArgs['--genLayout']) 55 | const MAX_CC = parsedArgs['--maxCC'] 56 | console.log(`generating words...`) 57 | 58 | const parentWords = randomWords({ exactly: PARENT_NODES_NUM, maxLength: 10 }) 59 | const childWords = randomWords({ exactly: CHILDREN_NODES_NUM, maxLength: 10 }) 60 | const childNodes: { id: string; title: string; cc: number; parentId: string }[] = 61 | [] 62 | const parentNodes: { id: string; title: string; cc: number; parentId: string }[] = 63 | [] 64 | const parentLinks: { source: string; target: string }[] = [] 65 | console.log(`generating graph...`) 66 | for (const i of [...Array(PARENT_NODES_NUM).keys()]) { 67 | parentNodes.push({ 68 | id: String(i), 69 | title: parentWords[i], 70 | cc: 0, 71 | parentId: `none`, 72 | }) 73 | } 74 | 75 | for (const i of [...Array(CHILDREN_NODES_NUM).keys()]) { 76 | const index = i + PARENT_NODES_NUM 77 | const parentId = Math.round(Math.random() * (PARENT_NODES_NUM - 1)) 78 | childNodes.push({ 79 | id: String(index), 80 | title: childWords[index - PARENT_NODES_NUM], 81 | cc: 1, 82 | parentId: String(parentId), 83 | }) 84 | parentLinks.push({ 85 | source: String(index), 86 | target: String(parentId), 87 | }) 88 | parentNodes[parentId].cc += 1 89 | } 90 | 91 | const d3Force = await import(`d3-force`) 92 | const { forceCenter, forceLink, forceManyBody, forceRadial, forceSimulation } = d3Force 93 | const randomLinks = [...Array(Math.round(CHILDREN_NODES_NUM / 4)).keys()] 94 | .filter((id) => id) 95 | .map((id) => { 96 | const parentId = Math.round(Math.random() * (PARENT_NODES_NUM - 1)) 97 | parentNodes[parentId].cc += 1 98 | return ({ 99 | source: String(id), 100 | target: String(parentId), 101 | }) 102 | }) 103 | const nodes = [...childNodes, ...parentNodes] 104 | const links = [...parentLinks, ...randomLinks] 105 | console.log(`generating layout...`) 106 | if (GENERATE_LAYOUT) { 107 | const forceLinks = forceLink(links) 108 | .id( 109 | (node) => 110 | // @ts-ignore 111 | node.id 112 | ) 113 | .distance(2000) 114 | // @ts-ignore 115 | const simulation = forceSimulation(nodes) 116 | .force(`link`, forceLinks) 117 | .force(`charge`, forceManyBody().strength(-40_000)) 118 | .force(`center`, forceCenter()) 119 | .force(`dagRadial`, forceRadial(1)) 120 | .stop() 121 | for (let i = 0; i < 10; ++i) { 122 | console.log(`${i * 5}th tick...`) 123 | simulation.tick(5) 124 | } 125 | } 126 | console.log(`generating json...`) 127 | const graph = { 128 | info: { 129 | "pre-computed layout": GENERATE_LAYOUT, 130 | nodesLength: nodes.length, 131 | linksLength: links.length, 132 | }, 133 | nodes, 134 | links, 135 | } 136 | const outputFileName = `prelayout-${GENERATE_LAYOUT}-nodes-${nodes.length}-links-${links.length}.json` 137 | fs.rmSync(path.resolve(`data`, outputFileName), { 138 | force: true, 139 | }) 140 | fs.writeFileSync(path.resolve('data', outputFileName), JSON.stringify(graph)) 141 | console.log(`finished...`) 142 | console.log(path.resolve(outputFileName)) 143 | process.exit(0) 144 | } 145 | 146 | main() 147 | -------------------------------------------------------------------------------- /packages/test-data/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | // "outDir": "./", /* Redirect output structure to the directory. */ 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | 59 | /* Experimental Options */ 60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | "resolveJsonModule": true 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "packageRules": [ 6 | { 7 | "matchPackagePatterns": [ 8 | "*" 9 | ], 10 | "matchUpdateTypes": [ 11 | "minor", 12 | "patch" 13 | ], 14 | "groupName": "all non-major dependencies", 15 | "groupSlug": "all-minor-patch" 16 | } 17 | ] 18 | } --------------------------------------------------------------------------------