├── .bumpversion.cfg ├── .github └── renovate.json ├── .gitignore ├── .nvmrc ├── .version ├── .vscode └── settings.json ├── Jenkinsfile ├── LICENSE ├── README.md ├── demo ├── .gitignore ├── index.html ├── index.tsx ├── logo.svg ├── public │ └── data.json ├── src │ ├── App.tsx │ ├── components │ │ ├── Controls.tsx │ │ ├── Drawer.css │ │ ├── Drawer.tsx │ │ ├── GraphOutlines.tsx │ │ ├── Layout.tsx │ │ ├── LoadingOverlay.css │ │ ├── LoadingOverlay.tsx │ │ ├── Logo.tsx │ │ ├── MousePosition.css │ │ ├── MousePosition.tsx │ │ ├── ReactIcon.tsx │ │ ├── ShuffleIcon.tsx │ │ ├── Slider.css │ │ ├── Slider.tsx │ │ ├── Toggle.css │ │ └── Toggle.tsx │ └── index.css └── vite.config.ts ├── package-lock.json ├── package.json ├── scripts └── prepublish.mjs ├── src ├── context.tsx ├── geo.tsx ├── hooks │ └── useEvent.ts ├── index.ts ├── ogma.tsx ├── overlay │ ├── canvas.tsx │ ├── index.tsx │ ├── layer.tsx │ ├── overlay.tsx │ ├── popup.tsx │ ├── tooltip.tsx │ ├── types.ts │ └── utils.ts ├── styles │ ├── classStyle.tsx │ ├── edgeStyle.tsx │ ├── index.ts │ └── nodeStyle.tsx ├── transformations │ ├── edgeFilter.tsx │ ├── edgeGrouping.tsx │ ├── index.tsx │ ├── neighborGeneration.tsx │ ├── neighborMerging.tsx │ ├── nodeCollapsing.tsx │ ├── nodeFilter.tsx │ ├── nodeGrouping.tsx │ ├── types.ts │ └── utils.ts ├── types.ts ├── utils.ts └── uuid.ts ├── test ├── classes.test.tsx ├── fixtures │ ├── simple_graph.json │ └── simple_graph_curved.json ├── ogma.test.tsx ├── popup.test.tsx ├── setup.ts ├── styles.test.tsx ├── transformations │ ├── edgeFilter.test.tsx │ ├── edgeGrouping.test.tsx │ ├── neighborGeneration.test.tsx │ ├── neighborMerging.test.tsx │ ├── nodeCollapsing.test.tsx │ ├── nodeFilter.test.tsx │ ├── nodeGrouping.test.tsx │ └── test-components.tsx └── utils.ts ├── tsconfig.build.json ├── tsconfig.json └── vite.config.ts /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 5.1.6 3 | commit = False 4 | tag = False 5 | serialize = 6 | {major}.{minor}.{patch}-{build} 7 | {major}.{minor}.{patch} 8 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)(-(?P[\w_-]+\.\d+))? 9 | 10 | [bumpversion:file:.version] 11 | [bumpversion:file:package.json] 12 | [bumpversion:file:package-lock.json] 13 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>Linkurious/renovate-config", 5 | "github>Linkurious/renovate-config:meta", 6 | "regexManagers:dockerfileVersions" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | reports 4 | web/index-bundle.* 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.18.3 -------------------------------------------------------------------------------- /.version: -------------------------------------------------------------------------------- 1 | 5.1.6 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[markdown]": { 4 | "editor.formatOnSave": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | @Library('linkurious-shared')_ 2 | 3 | nodeJob { 4 | projectName = "linkurious/ogma-react" 5 | podTemplateNames = ['jnlp-agent-node'] 6 | runPreReleaseOnUpload = false 7 | npmPackPath = './dist' 8 | createGitTag = true 9 | gitTagPrefix = 'v' 10 | runNpmPublish = true 11 | runDependencyVersionCheck = false 12 | runBookeeping = true 13 | githubRelease = true 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@linkurious/ogma-react` 2 | 3 | ![logo](/demo/logo.svg) 4 | 5 | Wrapper library for [`@linkurious/ogma`](https://ogma.linkurio.us) to use with [React](https://reactjs.org). 6 | 7 | * [Getting started](#getting-started) 8 | * [Usage](#usage) 9 | * [API](#api) 10 | - [``](#ogma-) 11 | - Styles: 12 | - [``](#nodestyle-) 13 | - [``](#edgestyle-) 14 | - [``](#styleclass-) 15 | - Overlays: 16 | - [``](#popup-) 17 | - [``](#tooltip-) 18 | - [``](#canvaslayer-) 19 | - [``](#layer-) 20 | - [``](#overlay-) 21 | - Transformations: 22 | - [``](#nodegrouping-) 23 | - [``](#edgegrouping-) 24 | - [``](#nodefilter-) 25 | - [``](#edgefilter-) 26 | - [``](#neighborgeneration-) 27 | - [``](#neighbormerging-) 28 | - [``](#nodecollapsing-) 29 | - [``](#geo-) 30 | 31 | ## Getting Started 32 | 33 | Add `@linkurious/ogma` and `@linkurious/ogma-react` to your project. For Ogma, you should use you NPM link from [get.linkurio.us](https://get.linkurio.us). 34 | 35 | ```bash 36 | npm install 37 | npm i @linkurious/ogma-react --save 38 | ``` 39 | 40 | Or, with yarn: 41 | 42 | ``` 43 | yarn i 44 | yarn add @linkurious/ogma-react 45 | ``` 46 | 47 | You will need the CSS or Styled Components (see [`web/src/index.css`](https://github.com/Linkurious/ogma-react/blob/develop/demo/src/index.css) for an example). No CSS is included by default. 48 | 49 | ```tsx 50 | import { Ogma, NodeStyle, Popup } from '@linkurious/ogma-react'; 51 | import { MouseButtonEvent, Node as OgmaNode } from '@linkurious/ogma'; 52 | ... 53 | const [clickedNode, setClickedNode] = useState(null); 54 | const onMouseMove = ({ target }: MouseButtonEvent) => { 55 | setClickedNode((target && target.isNode) ? target : null); 56 | } 57 | 58 | { 61 | ogma.events.on('click', onClick); 62 | }} 63 | > 64 | 65 | clickedNode ? clickedNode.getPosition() : null} 67 | > 68 |
Popup content here!
69 |
70 |
71 | ``` 72 | 73 | 74 | ## Usage 75 | 76 | See the [`web/src/App.tsx`](https://github.com/Linkurious/ogma-react/blob/develop/web/src/App.tsx) file for a complete example. 77 | 78 | ```tsx 79 | const graph: Ogma.RawGraph = ...; 80 | return 81 | ``` 82 | 83 | ### Custom components 84 | 85 | You can (and should) create your own components to implement different behaviors. It's easy, you just need to use the `useOgma` hook to get access to the instance of Ogma. 86 | 87 | ```tsx 88 | import { useOgma } from '@linkurious/ogma-react'; 89 | 90 | export function MyComponent() { 91 | const ogma = useOgma(); 92 | const onClick = useCallback(() => { 93 | ogma.getNodes([1,2,3,4]).setSelected(true); 94 | }, []); 95 | 96 | return ( 97 |
98 | 99 |
100 | ); 101 | } 102 | ``` 103 | 104 | ### How to apply the layouts 105 | 106 | It's unintuitive to implement the layouts as a React component declaratively. We suggest using custom components and hook to ogma events to apply the layouts. 107 | 108 | `components/LayoutService.tsx`: 109 | 110 | ```tsx 111 | import { useEffect } from 'react'; 112 | import { useOgma } from '@linkurious/ogma-react'; 113 | export function LayoutService () { 114 | const ogma = useOgma(); // hook to get the ogma instance 115 | 116 | useEffect(() => { 117 | const onNodesAdded = () => { 118 | // apply your layout 119 | } 120 | ogma.events.on('addNodes', onNodesAdded); 121 | 122 | // cleanup 123 | return () => { 124 | ogma.events.off(onNodesAdded); 125 | }; 126 | }, []); 127 | 128 | return null; 129 | } 130 | ``` 131 | 132 | 133 | `App.tsx`: 134 | ```tsx 135 | import { LayoutService } from './components/LayoutService'; 136 | 137 | export default function App() { 138 | ... // retrive the graph here 139 | 140 | return ( 141 | 142 | ); 143 | } 144 | ``` 145 | 146 | ### How to load the graph 147 | 148 | ```tsx 149 | import { useState, useEffect } from 'react'; 150 | import { RawGraph } from '@linkurious/ogma'; 151 | import { Ogma } from '@linkurious/ogma-react'; 152 | 153 | export default function App () { 154 | const [isLoading, setIsLoading] = useState(true); 155 | const [graph, setGraph] = useState(); 156 | 157 | useEffect(() => { 158 | fetch('/graph.json') 159 | .then(res => res.json()) 160 | .then(json => { 161 | setGraph(json); 162 | setIsLoading(false); 163 | }); 164 | }, []); 165 | 166 | if (isLoading) return (
Loading...
); 167 | return (); 168 | } 169 | ``` 170 | Using the parsers: 171 | 172 | ```tsx 173 | import { useState, useEffect } from 'react'; 174 | import OgmaLib, { RawGraph } from '@linkurious/ogma'; 175 | import { Ogma } from '@linkurious/ogma-react'; 176 | 177 | export default function App () { 178 | const [isLoading, setIsLoading] = useState(true); 179 | const [graph, setGraph] = useState(); 180 | 181 | // using ogma parser to parse GEXF format 182 | useEffect(() => { 183 | fetch('/graph.gexf') 184 | .then(res => res.text()) 185 | .then(gexf => OgmaLib.parse.gexf(gexf)) 186 | .then(jsonGraph => { 187 | setGraph(jsonGraph); 188 | setIsLoading(false); 189 | }); 190 | }, []); 191 | 192 | if (isLoading) return (
Loading...
); 193 | 194 | return () 195 | } 196 | ``` 197 | 198 | ## Components 199 | 200 | - [``](#ogma-) 201 | - Styles: 202 | - [``](#nodestyle-) 203 | - [``](#edgestyle-) 204 | - [``](#styleclass-) 205 | - Overlays: 206 | - [``](#popup-) 207 | - [``](#tooltip-) 208 | - [``](#canvaslayer-) 209 | - [``](#layer-) 210 | - [``](#overlay-) 211 | - Transformations: 212 | - [``](#nodegrouping-) 213 | - [``](#edgegrouping-) 214 | - [``](#nodefilter-) 215 | - [``](#edgefilter-) 216 | - [``](#neighborgeneration-) 217 | - [``](#neighbormerging-) 218 | - [``](#nodecollapsing-) 219 | - [``](#geo-) 220 | 221 | ## API 222 | 223 | ### `` 224 | 225 | Main visualisation component. You can use `onReady` or `ref` prop to get a reference to the Ogma instance. 226 | 227 | #### Props 228 | 229 | | Prop | Type | Default | Description | 230 | | ---------- | ---------------------- | ------- | ----------- | 231 | | `options?` | [`Ogma.Options`](https://doc.linkurio.us/ogma/latest/api.html#Options) | `{}` | Ogma options | 232 | | `graph?` | `Ogma.RawGraph` | `null` | The graph to render | 233 | | `onReady?` | `(ogma: Ogma) => void` | `null` | Callback when the Ogma instance is ready | 234 | | `ref?` | `React.Ref` | `null` | Reference to the Ogma instance | 235 | | `children?` | `React.ReactNode` | `null` | The children of the component, such as `` or `` or your custom component. Ogma instance is avalable to the children components through `useOgma()` hook | 236 | | `theme?` | `Ogma.Themes` | `null` | The theme of the graph. Keep in mind that adding `` and `` components will overwrite the theme's styles | 237 | | `onEventName` | `(event: EventTypes[K]) => void` | `null` | The handler for an [event](https://doc.linkurious.com/ogma/latest/api/events.html). The passed in function should always be the result of the `useEvent` hook to have a stable function identity and avoid reassigning the same handler at every render. | 238 | 239 | ### `` 240 | 241 | Node style component. 242 | 243 | #### Props 244 | 245 | | Prop | Type | Default | Description | 246 | | ------------ | ------------------------------ | ------- | -------------------------------------------- | 247 | | `attributes` | `Ogma.NodeAttributeValue` | `{}` | Attributes to apply to the node | 248 | | `selector?` | `(node: Ogma.Node) => boolean` | `null` | Selector to apply the attributes to the node | 249 | | `ref?` | `React.Ref` | `null` | Reference to the style rule | 250 | 251 | #### Example 252 | 253 | ```tsx 254 | 255 | 256 | 257 | ``` 258 | 259 | ### `` 260 | 261 | Edge style component. 262 | 263 | #### Props 264 | 265 | | Prop | Type | Default | Description | 266 | | ------------ | ------------------------------ | ------- | -------------------------------------------- | 267 | | `attributes` | `Ogma.EdgeAttributeValue` | `{}` | Attributes to apply to the edge | 268 | | `selector?` | `(edge: Ogma.Edge) => boolean` | `null` | Selector to apply the attributes to the edge | 269 | | `ref?` | `React.Ref` | `null` | Reference to the style rule | 270 | 271 | #### Example 272 | 273 | ```tsx 274 | 275 | 276 | 277 | ``` 278 | 279 | ### `` 280 | 281 | Wrapper to the Ogma `StyleClass` class. It allows you to apply styles to nodes and edges based on their `class`, much like in CSS. 282 | 283 | #### Props 284 | | Prop | Type | Default | Description | 285 | | ------------ | ------------------------------ | ------- | -------------------------------------------- | 286 | | **`name`** | `string` | | The class name to apply the styles to | 287 | | `nodeAttributes` | `NodeAttributeValue` | `{}` | Attributes to apply to the nodes or edges | 288 | | `edgeAttributes` | `EdgeAttributeValue` | `{}` | Attributes to apply to the edges | 289 | 290 | 291 | ### Example 292 | 293 | ```tsx 294 | useEffect(() => { 295 | ogma.getNode('x').addClass('my-class'); 296 | }, []); 297 | 298 | return ( 299 | 304 | ); 305 | ``` 306 | 307 | ### `` 308 | 309 | Custom popup UI layer. 310 | 311 | #### Props 312 | 313 | | Prop | Type | Default | Description | 314 | | ------------------ | ------------------ | ----------------------- | ----------------------------------------------------------- | 315 | | `position` | `Point \| (ogma: Ogma) => Point` | `null` | Position of the popup | 316 | | `size?` | `{ width: number \| 'auto'; height: number \| 'auto'; }` | `{ width: 'auto', height: 'auto' }` | Size of the popup | 317 | | `children` | `React.ReactNode` | `null` | The children of the component | 318 | | `isOpen` | `boolean` | `true` | Whether the popup is open | 319 | | `onClose` | `() => void` | `null` | Callback when the popup is closed | 320 | | `placement` | `'top' \| 'bottom' \| 'right'\| 'left'` | Placement of the popup | 321 | | `ref?` | `React.Ref` | `null` | Reference to the popup | 322 | | `closeOnEsc?` | `boolean` | `true` | Whether to close the popup when the user presses the ESC key | 323 | | `popupClass?` | `string` | `'ogma-popup'` | Class name to apply to the popup container | 324 | | `contentClass?` | `string` | `'ogma-popup--content'` | Class name to apply to the popup content | 325 | | `popupBodyClass?` | `string` | `'ogma-popup--body'` | Class name to apply to the popup body | 326 | | `closePopupClass?` | `string` | `'ogma-popup--close'` | Class name to apply to the close button | 327 | 328 | #### Example 329 | 330 | ```tsx 331 | 332 | (clickedNode ? clickedNode.getPosition() : null)} 334 | size={{ width: 200, height: 200 }} 335 | > 336 |
Popup content here!
337 |
338 |
339 | ``` 340 | 341 | ### `` 342 | 343 | Tooltip component. Use it for cutom movable tooltips. It automatically adjusts the placement of the tooltip to conainer bounds. 344 | 345 | #### Props 346 | 347 | | Prop | Type | Default | Description | 348 | | ----------- | ----------------- | ---------------------- | ----------------------------- | 349 | | `position` | `Point \| (ogma: Ogma) => Point` | | Position of the tooltip | 350 | | `size?` | `{ width: number \| 'auto'; height: number \| 'auto'; }` | `{ width: 'auto', height: 'auto' }` | Size of the tooltip | 351 | | `children` | `React.ReactNode` | `null` | The children of the component | 352 | | `visible` | `boolean` | `true` | Whether the tooltip is open | 353 | | `placement` | `Placement` | `right` | Placement of the tooltip | 354 | | `ref?` | `React.Ref` | `null` | Reference to the tooltip | 355 | | `tooltipClass` | `string` | `'ogma-tooltip'` | Class name to apply to the tooltip container | 356 | 357 | #### Example 358 | 359 | ```tsx 360 | 361 | (hoveredNode ? hoveredNode.getPosition() : null)} 364 | size={{ width: 200, height: 200 }} 365 | > 366 |
Tooltip content here!
367 |
368 |
369 | ``` 370 | 371 | ### `` 372 | 373 | Custom canvas layer. 374 | 375 | #### Props 376 | 377 | | Prop | Type | Default | Description | 378 | | ---- | ---- | ------- | ----------- | 379 | | `ref` | `React.Ref` | `null` | Reference to the canvas layer | 380 | | `render` | `(ctx: CanvasRenderingContext2D) => void` | `null` | Callback to render the canvas layer | 381 | | `index?` | `number` | `1` | Index of the layer | 382 | | `isStatic?` | `boolean` | `false` | Whether the layer is static | 383 | | `noClear?` | `boolean` | `false` | Whether to clear the canvas before rendering | 384 | 385 | #### Example 386 | 387 | ```tsx 388 | 389 | { 391 | ctx.fillStyle = 'red'; 392 | ctx.fillRect(0, 0, 100, 100); 393 | }} 394 | /> 395 | 396 | ``` 397 | 398 | ### `` 399 | 400 | Generic DOM layer, see [`ogma.layers.addLayer`](https://doc.linkurious.com/ogma/latest/api.html#Ogma-layers-addLayer). 401 | 402 | #### Props 403 | 404 | | Prop | Type | Default | Description | 405 | | ---- | ---- | ------- | ----------- | 406 | | `children` | `React.ReactNode` | `null`| The children of the layer | 407 | #### Example 408 | 409 | ```tsx 410 | 411 | 412 | Layer content here! 413 | 414 | 415 | ``` 416 | 417 | ### `` 418 | 419 | Generic Overlay layer, see [`ogma.layers.addOverlay`](https://doc.linkurious.com/ogma/latest/api.html#Ogma-layers-addOverlay). 420 | 421 | #### Props 422 | 423 | | Prop | Type | Default | Description | 424 | | ---- | ---- | ------- | ----------- | 425 | | `children` | `React.ReactNode` | `null`| The children of the layer | 426 | | `className` | `string` | `null`| Classname for the Overlay | 427 | | `scaled` | `boolean` | `true`| Wether the Overlay is scaled on zoom or not | 428 | | `position` | `Point \| (ogma: Ogma) => Point` | | Position of the Overlay | 429 | | `size?` | `{ width: number \| 'auto'; height: number \| 'auto'; }` | `{ width: 'auto', height: 'auto' }` | Size of the Overlay | 430 | 431 | #### Example 432 | 433 | ```tsx 434 | 435 | 436 | Layer content here! 437 | 438 | 439 | ``` 440 | 441 | ## Transformations 442 | 443 | All transformations have callback props, making it easy to react to events related to transformations. 444 | | Prop | Type | Default | Description | 445 | | ------------ | ------------------------------ | ------- | ----------- | 446 | | `onEnabled` | `(t: Transformation) => void` | `null` | Triggered when transformation is enabled | 447 | | `onUpdated` | `(t: Transformation) => void` | `null` | Triggered when transformation is refreshed | 448 | | `onDisabled` | `(t: Transformation) => void` | `null` | Triggered when transformation is disabled | 449 | | `onDestroyed`| `(t: Transformation) => void` | `null` | Triggered when transformation is destroyed | 450 | | `onSetIndex` | `(t: Transformation, i: number) => void` | `null` | Triggered when transformation changes index | 451 | 452 | 453 | ### `` 454 | 455 | Node grouping transformation. See [`ogma.transformations.addNodeGrouping()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addNodeGrouping) for more details. 456 | 457 | #### Props 458 | 459 | | Prop | Type | Default | Description | 460 | | ------------ | ------------------------------ | ------- | ----------- | 461 | | `selector` | `(node: Ogma.Node) => boolean` | `null` | Selector to apply the attributes to the node | 462 | | `groupIdFunction` | `(node: Ogma.Node) => string \| undefined` | | Grouping function | 463 | | `ref?` | `React.Ref` | `null` | Reference to the transformation | 464 | | `...rest` | See [`ogma.transformations.addNodeGrouping()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addNodeGrouping) properties | | Node grouping transformation properties | 465 | 466 | #### Example 467 | 468 | ```tsx 469 | 470 | node.getAttribute('type') === 'type1'} 472 | groupIdFunction={node => node.getAttribute('type')} 473 | disabled={false} 474 | /> 475 | 476 | ``` 477 | 478 | ### `` 479 | 480 | Edge grouping transformation. See [`ogma.transformations.addEdgeGrouping()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addEdgeGrouping) for more information. 481 | 482 | #### Props 483 | 484 | | Prop | Type | Default | Description | 485 | | ------------ | ------------------------------ | ------- | ----------- | 486 | | `selector` | `(edge: Ogma.Edge) => boolean` | `null` | Selector for the edges | 487 | | `groupIdFunction` | `(edge: Ogma.Edge) => string \| undefined` | | Grouping function | 488 | | `ref?` | `React.Ref` | `null` | Reference to the transformation | 489 | | `...rest` | See [`ogma.transformations.addEdgeGrouping()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addEdgeGrouping) properties | | Edge grouping transformation properties | 490 | 491 | 492 | #### Example 493 | 494 | ```tsx 495 | 496 | edge.getAttribute('type') === 'type1'} 498 | groupIdFunction={edge => edge.getAttribute('type')} 499 | disabled={false} 500 | /> 501 | 502 | ``` 503 | 504 | ### `` 505 | 506 | Node filter transformation. See [`ogma.transformations.addNodeFilter()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addNodeFilter) for more information. 507 | 508 | #### Props 509 | 510 | | Prop | Type | Default | Description | 511 | | ------------ | ------------------------------ | ------- | ----------- | 512 | | `...props` | `Ogma.NodeFilterOptions` | | See [`ogma.transformations.addNodeFilter()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addNodeFilter) for more information. | 513 | 514 | #### Example 515 | 516 | ```tsx 517 | 518 | node.getData('age') > 22} 520 | disabled={false} 521 | /> 522 | 523 | ``` 524 | 525 | ### `` 526 | 527 | Wrapper for the edge filter transformation. See [`ogma.transformations.addEdgeFilter()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addEdgeFilter) for more information. 528 | 529 | #### Props 530 | 531 | | Prop | Type | Default | Description | 532 | | ------------ | ------------------------------ | ------- | ----------- | 533 | | `...props` | `Ogma.EdgeFilterOptions` | | See [`ogma.transformations.addEdgeFilter()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addEdgeFilter) for more information. | 534 | 535 | #### Example 536 | 537 | ```tsx 538 | 539 | edge.getData('type') === 'important'} 541 | disabled={false} 542 | /> 543 | 544 | ``` 545 | 546 | ### `` 547 | 548 | Neighbor merging transformation. See [`ogma.transformations.addNeighborMerging()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addNeighborMerging) for more information. 549 | 550 | #### Props 551 | 552 | | Prop | Type | Default | Description | 553 | | ------------ | ------------------------------ | ------- | ----------- | 554 | | `selector` | `(node: Ogma.Node) => boolean` | `null` | Selector | 555 | | `dataFunction` | `(node: Ogma.Node) => object | undefined;` | | Neighbor data function | 556 | | `ref?` | `React.Ref` | `null` | Reference to the transformation | 557 | | `...rest` | See [`ogma.transformations.addNeighborMerging()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addNeighborMerging) properties | | Neighbor merging transformation properties | 558 | 559 | #### Example 560 | 561 | ```tsx 562 | 563 | node.getAttribute('type') === 'type1'} 565 | dataFunction={node => ({ 566 | type: node.getAttribute('type'), 567 | label: node.getAttribute('label'), 568 | })} 569 | disabled={false} 570 | /> 571 | 572 | ``` 573 | 574 | ### `` 575 | 576 | Neighbor generation transformation. See [`ogma.transformations.addNeighborGeneration()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addNeighborGeneration) for more information. 577 | 578 | #### Props 579 | 580 | | Prop | Type | Default | Description | 581 | | ------------ | ------------------------------ | ------- | ----------- | 582 | | `selector` | `(node: Ogma.Node) => boolean` | `null` | Selector | 583 | | `neighborIdFunction` | `(node: Ogma.Node) => string|Array|null;` | | Neighbor data function | 584 | | `ref?` | `React.Ref` | `null` | Reference to the transformation | 585 | | `...rest` | See [`ogma.transformations.addNeighborMerging()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addNeighborGeneration) properties | | Transformation properties | 586 | 587 | #### Example 588 | 589 | ```tsx 590 | 591 | node.getAttribute('type') === 'type1'} 593 | neighborIdFunction={node => node.getAttribute('type')} 594 | disabled={false} 595 | /> 596 | 597 | ``` 598 | 599 | ### `` 600 | 601 | Node collapsing transformation. See [`ogma.transformations.addNodeCollapsing()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addNodeCollapsing) for more information. 602 | 603 | #### Props 604 | 605 | | Prop | Type | Default | Description | 606 | | ------------ | ------------------------------ | ------- | ----------- | 607 | | `selector` | `(node: Ogma.Node) => boolean` | `null` | Selector | 608 | | `edgeGenerator?` | `(hiddenNode: Ogma.Node, node1: Ogma.Node, node2: Ogma.Node, edges1: Ogma.EdgeList, edges2: Ogma.EdgeList): RawEdge|null)` | | Edge generator function | 609 | | `ref?` | `React.Ref` | `null` | Reference to the transformation | 610 | | `...rest` | See [`ogma.transformations.addNodeCollapsing()`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addNodeCollapsing) properties | | Transformation properties | 611 | 612 | 613 | ### `` 614 | 615 | Geo mode component. It's the first version of this component and we are still gathering feedback on how you can use it. 616 | 617 | #### Props 618 | 619 | | Prop | Type | Default | Description | 620 | | ------------ | ------------------------------ | ------- | ----------- | 621 | | `enabled?` | `boolean` | `false` | On/off toggle | 622 | | `...rest` | `Ogma.GeomModeOptions` | | See [`GeoModeOptions`](https://doc.linkurio.us/ogma/latest/api.html#GeoModeOptions) properties | 623 | 624 | #### Example 625 | 626 | ```tsx 627 | 628 | 634 | 635 | ``` 636 | 637 | 638 | ## `` incompatibility 639 | 640 | If you are using `` in your application, you may encounter issues with the Ogma instance being created multiple times. This is due to the way React.StrictMode works, which intentionally invokes components twice to help identify side effects. It's not compatible with the way components like `` are designed to work. 641 | To avoid this issue, we highly recommend not using `` in your application when using `@linkurious/ogma-react`. If you need to use strict mode, consider wrapping only parts of your application that do not include Ogma components or implementing the mount counters. 642 | 643 | 644 | 645 | ## License 646 | 647 | Apache 2.0 648 | 649 | 650 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React Ogma component 5 | 9 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /demo/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import "./src/index.css"; 3 | import App from "./src/App"; 4 | 5 | const container = document.getElementById("root")!; 6 | const root = createRoot(container); 7 | 8 | root.render(); 9 | -------------------------------------------------------------------------------- /demo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /demo/public/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": [ 3 | { 4 | "id": 8666, 5 | "attributes": { 6 | "x": 25.858288814166933, 7 | "y": -7.103984513542148, 8 | "color": "grey", 9 | "radius": 5, 10 | "shape": "circle", 11 | "text": "Elon Musk" 12 | }, 13 | "data": { 14 | "properties": { 15 | "name": "Elon Musk", 16 | "permalink": "/person/elon-musk", 17 | "url": "http://www.crunchbase.com/person/elon-musk", 18 | "picture": "img/musk.jpg" 19 | }, 20 | "categories": ["INVESTOR"] 21 | } 22 | }, 23 | { 24 | "id": 6870, 25 | "attributes": { 26 | "x": 0.09298533946275711, 27 | "y": 0.41120362281799316, 28 | "color": "#f35371", 29 | "radius": 10, 30 | "shape": "circle", 31 | "text": "SolarCity" 32 | }, 33 | "data": { 34 | "properties": { 35 | "name": "SolarCity", 36 | "permalink": "/organization/solarcity", 37 | "founded_at": "01/01/2006", 38 | "first_funding_at": "15/09/2006", 39 | "market": " Construction ", 40 | "founded_quarter": "2006-Q1", 41 | "founded_year": 2006, 42 | "state": "CA", 43 | "url": "http://www.crunchbase.com/organization/solarcity", 44 | "country": "USA", 45 | "homepage_url": "http://www.solarcity.com", 46 | "logo": "http://www.crunchbase.com/organization/solarcity/primary-image/raw", 47 | "founded_month": "2006-01", 48 | "funding_rounds": 13, 49 | "status": "operating", 50 | "funding_total": 1045040000, 51 | "region": "SF Bay Area", 52 | "category": "|Construction|Clean Technology|", 53 | "last_funding_at": "18/06/2013" 54 | }, 55 | "categories": ["COMPANY", "INVESTOR"] 56 | } 57 | }, 58 | { 59 | "id": 7396, 60 | "attributes": { 61 | "x": 85.876122673705, 62 | "y": -21.207117111759345, 63 | "color": "#cd476a", 64 | "radius": 8.833333333333334, 65 | "shape": "circle", 66 | "text": "Tesla Motors" 67 | }, 68 | "data": { 69 | "properties": { 70 | "name": "Tesla Motors", 71 | "permalink": "/organization/tesla-motors", 72 | "founded_at": "01/01/2003", 73 | "first_funding_at": "01/04/2004", 74 | "market": " Automotive ", 75 | "founded_quarter": "2003-Q1", 76 | "founded_year": 2003, 77 | "state": "CA", 78 | "url": "http://www.crunchbase.com/organization/tesla-motors", 79 | "country": "USA", 80 | "homepage_url": "http://www.teslamotors.com", 81 | "logo": "http://www.crunchbase.com/organization/tesla-motors/primary-image/raw", 82 | "founded_month": "2003-01", 83 | "funding_rounds": 11, 84 | "status": "operating", 85 | "funding_total": 823000000, 86 | "region": "SF Bay Area", 87 | "category": "|Automotive|", 88 | "last_funding_at": "10/10/2012", 89 | "latitude": 49.2677, 90 | "longitude": -123.1418 91 | }, 92 | "categories": ["COMPANY"] 93 | } 94 | }, 95 | { 96 | "id": 2982, 97 | "attributes": { 98 | "x": -27.6874947092224, 99 | "y": -21.207117111759345, 100 | "color": "#632654", 101 | "radius": 5.333333333333334, 102 | "shape": "circle", 103 | "text": "First Solar" 104 | }, 105 | "data": { 106 | "properties": { 107 | "name": "First Solar", 108 | "permalink": "/organization/first-solar", 109 | "founded_at": "01/01/1999", 110 | "first_funding_at": "18/06/2013", 111 | "market": " Semiconductors ", 112 | "founded_quarter": "1999-Q1", 113 | "founded_year": 1999, 114 | "state": "AZ", 115 | "url": "http://www.crunchbase.com/organization/first-solar", 116 | "country": "USA", 117 | "homepage_url": "http://www.firstsolar.com", 118 | "logo": "http://www.crunchbase.com/organization/first-solar/primary-image/raw", 119 | "founded_month": "1999-01", 120 | "funding_rounds": 1, 121 | "status": "operating", 122 | "funding_total": 427700000, 123 | "region": "Phoenix", 124 | "category": "|Semiconductors|Clean Technology|", 125 | "last_funding_at": "18/06/2013", 126 | "latitude": 39.2677, 127 | "longitude": -113.1418 128 | }, 129 | "categories": ["COMPANY", "INVESTOR"] 130 | } 131 | }, 132 | { 133 | "id": 3388, 134 | "attributes": { 135 | "x": 17.30193137441551, 136 | "y": -22.990838850758205, 137 | "color": "#161344", 138 | "radius": 3, 139 | "shape": "circle", 140 | "text": "Google" 141 | }, 142 | "data": { 143 | "properties": { 144 | "name": "Google", 145 | "permalink": "/organization/google", 146 | "founded_at": "07/09/1998", 147 | "first_funding_at": "01/08/1998", 148 | "market": " Software ", 149 | "founded_quarter": "1998-Q3", 150 | "founded_year": 1998, 151 | "state": "CA", 152 | "url": "http://www.crunchbase.com/organization/google", 153 | "country": "USA", 154 | "homepage_url": "https://www.google.com", 155 | "logo": "http://www.crunchbase.com/organization/google/primary-image/raw", 156 | "founded_month": "1998-09", 157 | "funding_rounds": 2, 158 | "status": "operating", 159 | "funding_total": 25100000, 160 | "region": "SF Bay Area", 161 | "category": "|Software|Video Streaming|Information Technology|Blogging Platforms|Email|Search|", 162 | "last_funding_at": "07/06/1999", 163 | "latitude": 37.422131, 164 | "longitude": -122.084801 165 | }, 166 | "categories": ["COMPANY"] 167 | } 168 | }, 169 | { 170 | "id": 8372, 171 | "attributes": { 172 | "x": -30.065790361220877, 173 | "y": -2.3789432001047315, 174 | "color": "#161344", 175 | "radius": 3, 176 | "shape": "circle", 177 | "text": "Zep Solar" 178 | }, 179 | "data": { 180 | "properties": { 181 | "name": "Zep Solar", 182 | "permalink": "/organization/zep-solar", 183 | "founded_at": "01/01/2009", 184 | "first_funding_at": "20/11/2013", 185 | "market": " Clean Technology ", 186 | "founded_quarter": "2009-Q1", 187 | "founded_year": 2009, 188 | "state": "CA", 189 | "url": "http://www.crunchbase.com/organization/zep-solar", 190 | "country": "USA", 191 | "homepage_url": "http://www.zepsolar.com", 192 | "logo": "http://www.crunchbase.com/organization/zep-solar/primary-image/raw", 193 | "founded_month": "2009-01", 194 | "funding_rounds": 1, 195 | "status": "acquired", 196 | "funding_total": 10571182, 197 | "region": "SF Bay Area", 198 | "category": "|Renewable Energies|Solar|Clean Technology|", 199 | "last_funding_at": "20/11/2013", 200 | "latitude": 29.2677, 201 | "longitude": -83.1418 202 | }, 203 | "categories": ["COMPANY"] 204 | } 205 | }, 206 | { 207 | "id": 8609, 208 | "attributes": { 209 | "x": 40.886696590067096, 210 | "y": 13.674552450884995, 211 | "color": "grey", 212 | "radius": 5, 213 | "shape": "circle", 214 | "text": "Draper Fisher Jurvetson (DFJ)" 215 | }, 216 | "data": { 217 | "properties": { 218 | "name": "Draper Fisher Jurvetson (DFJ)", 219 | "permalink": "/organization/draper-fisher-jurvetson", 220 | "market": "Venture Capital", 221 | "state": "CA", 222 | "url": "http://www.crunchbase.com/organization/draper-fisher-jurvetson", 223 | "country": "USA", 224 | "region": "SF Bay Area", 225 | "category": "|Venture Capital|", 226 | "city": "Menlo Park", 227 | "latitude": 37.27, 228 | "longitude": -122.11 229 | }, 230 | "categories": ["INVESTOR"] 231 | } 232 | }, 233 | { 234 | "id": 9209, 235 | "attributes": { 236 | "x": 2.2393922450918033, 237 | "y": -46.971986675076195, 238 | "color": "grey", 239 | "radius": 5, 240 | "shape": "circle", 241 | "text": "Valor Equity Partners" 242 | }, 243 | "data": { 244 | "properties": { 245 | "name": "Valor Equity Partners", 246 | "permalink": "/organization/valor-equity-partners", 247 | "state": "IL", 248 | "url": "http://www.crunchbase.com/organization/valor-equity-partners", 249 | "country": "USA", 250 | "region": "Chicago", 251 | "city": "Chicago", 252 | "latitude": 41.88, 253 | "longitude": -87.37 254 | }, 255 | "categories": ["INVESTOR"] 256 | } 257 | }, 258 | { 259 | "id": 9967, 260 | "attributes": { 261 | "x": 56.34561832805721, 262 | "y": 33.890065492872054, 263 | "color": "grey", 264 | "radius": 5, 265 | "shape": "circle", 266 | "text": "DFJ Growth" 267 | }, 268 | "data": { 269 | "properties": { 270 | "name": "DFJ Growth", 271 | "permalink": "/organization/dfj-growth", 272 | "market": "Entrepreneur", 273 | "state": "CA", 274 | "url": "http://www.crunchbase.com/organization/dfj-growth", 275 | "country": "USA", 276 | "region": "SF Bay Area", 277 | "category": "|Entrepreneur|", 278 | "city": "Menlo Park", 279 | "latitude": 41.88, 280 | "longitude": -87.37 281 | }, 282 | "categories": ["INVESTOR"] 283 | } 284 | }, 285 | { 286 | "id": 10229, 287 | "attributes": { 288 | "x": -24.912816448557507, 289 | "y": 16.845613320216298, 290 | "color": "grey", 291 | "radius": 5, 292 | "shape": "circle", 293 | "text": "U.S. Bancorp" 294 | }, 295 | "data": { 296 | "properties": { 297 | "name": "U.S. Bancorp", 298 | "permalink": "/organization/u-s-bancorp", 299 | "market": "Financial Services", 300 | "state": "MN", 301 | "url": "http://www.crunchbase.com/organization/u-s-bancorp", 302 | "country": "USA", 303 | "region": "Minneapolis", 304 | "category": "|Investment Management|Banking|Financial Services|", 305 | "city": "Minneapolis", 306 | "latitude": 44.9778, 307 | "longitude": -93.265 308 | }, 309 | "categories": ["INVESTOR"] 310 | } 311 | }, 312 | { 313 | "id": 124, 314 | "attributes": { 315 | "x": -61.38001644586753, 316 | "y": 2.179456799559018, 317 | "color": "grey", 318 | "radius": 5, 319 | "shape": "circle", 320 | "text": " Clean Technology " 321 | }, 322 | "data": { 323 | "properties": { "name": " Clean Technology " }, 324 | "categories": ["MARKET"] 325 | } 326 | }, 327 | { 328 | "id": 6866, 329 | "attributes": { 330 | "x": -59.99267731553508, 331 | "y": 25.962413319543796, 332 | "color": "#3f1c4c", 333 | "radius": 4.166666666666667, 334 | "shape": "circle", 335 | "text": "Solar Power Partners" 336 | }, 337 | "data": { 338 | "properties": { 339 | "name": "Solar Power Partners", 340 | "permalink": "/organization/solar-power-partners", 341 | "founded_at": "01/01/2006", 342 | "first_funding_at": "18/09/2007", 343 | "market": " Clean Technology ", 344 | "founded_quarter": "2006-Q1", 345 | "founded_year": 2006, 346 | "state": "CA", 347 | "url": "http://www.crunchbase.com/organization/solar-power-partners", 348 | "country": "USA", 349 | "homepage_url": "http://www.solarpowerpartners.com", 350 | "logo": "http://www.crunchbase.com/organization/solar-power-partners/primary-image/raw", 351 | "founded_month": "2006-01", 352 | "funding_rounds": 4, 353 | "status": "acquired", 354 | "funding_total": 253000000, 355 | "region": "SF Bay Area", 356 | "category": "|Clean Technology|", 357 | "last_funding_at": "27/04/2010" 358 | }, 359 | "categories": ["COMPANY"] 360 | } 361 | }, 362 | { 363 | "id": 7163, 364 | "attributes": { 365 | "x": -26.69653818755637, 366 | "y": 46.17792636153085, 367 | "color": "#86315b", 368 | "radius": 6.5, 369 | "shape": "circle", 370 | "text": "Sunrun" 371 | }, 372 | "data": { 373 | "properties": { 374 | "name": "Sunrun", 375 | "permalink": "/organization/sunrun", 376 | "founded_at": "01/01/2007", 377 | "first_funding_at": "20/06/2008", 378 | "market": " Solar ", 379 | "founded_quarter": "2007-Q1", 380 | "founded_year": 2007, 381 | "state": "CA", 382 | "url": "http://www.crunchbase.com/organization/sunrun", 383 | "country": "USA", 384 | "homepage_url": "http://www.sunrun.com", 385 | "logo": "http://www.crunchbase.com/organization/sunrun/primary-image/raw", 386 | "founded_month": "2007-01", 387 | "funding_rounds": 9, 388 | "status": "operating", 389 | "funding_total": 486600000, 390 | "region": "SF Bay Area", 391 | "category": "|Clean Energy|Residential Solar|Solar|Clean Technology|", 392 | "last_funding_at": "01/09/2014" 393 | }, 394 | "categories": ["COMPANY"] 395 | } 396 | }, 397 | { 398 | "id": 5418, 399 | "attributes": { 400 | "x": -60.587251228534704, 401 | "y": -24.972751894090273, 402 | "color": "#161344", 403 | "radius": 3, 404 | "shape": "circle", 405 | "text": "OptiSolar R&D" 406 | }, 407 | "data": { 408 | "properties": { 409 | "name": "OptiSolar R&D", 410 | "permalink": "/organization/optisolar", 411 | "founded_at": "01/01/2002", 412 | "first_funding_at": "11/04/2008", 413 | "market": " Clean Technology ", 414 | "founded_quarter": "2002-Q1", 415 | "founded_year": 2002, 416 | "state": "CA", 417 | "url": "http://www.crunchbase.com/organization/optisolar", 418 | "country": "USA", 419 | "homepage_url": "http://www.optisolar.com", 420 | "logo": "http://www.crunchbase.com/organization/optisolar/primary-image/raw", 421 | "founded_month": "2002-01", 422 | "funding_rounds": 1, 423 | "status": "acquired", 424 | "funding_total": 132000000, 425 | "region": "SF Bay Area", 426 | "category": "|Manufacturing|Solar|Clean Technology|", 427 | "last_funding_at": "11/04/2008" 428 | }, 429 | "categories": ["COMPANY"] 430 | } 431 | } 432 | ], 433 | "edges": [ 434 | { 435 | "id": 13337, 436 | "source": 8372, 437 | "target": 124, 438 | "attributes": { "color": "grey", "width": 1, "text": "HAS_MARKET" }, 439 | "data": { "type": "HAS_MARKET" } 440 | }, 441 | { 442 | "id": 13867, 443 | "source": 8666, 444 | "target": 7396, 445 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 446 | "data": { 447 | "properties": { 448 | "permalink": "/funding-round/3509cccde65780f038402c23df4132c3", 449 | "funded_month": "2004-04", 450 | "funded_quarter": "2004-Q2", 451 | "funded_year": 2004, 452 | "funded_at": "01/04/2004", 453 | "funding_round_type": "venture", 454 | "funding_round_code": "A", 455 | "raised_amount_usd": 7500000 456 | }, 457 | "type": "INVESTED_IN" 458 | } 459 | }, 460 | { 461 | "id": 14162, 462 | "source": 9209, 463 | "target": 7396, 464 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 465 | "data": { 466 | "properties": { 467 | "permalink": "/funding-round/ee3faaa92354502d4cffb0cd602c35f8", 468 | "funded_month": "2005-02", 469 | "funded_quarter": "2005-Q1", 470 | "funded_year": 2005, 471 | "funded_at": "01/02/2005", 472 | "funding_round_type": "venture", 473 | "funding_round_code": "B", 474 | "raised_amount_usd": 13000000 475 | }, 476 | "type": "INVESTED_IN" 477 | } 478 | }, 479 | { 480 | "id": 14166, 481 | "source": 8666, 482 | "target": 7396, 483 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 484 | "data": { 485 | "properties": { 486 | "permalink": "/funding-round/ee3faaa92354502d4cffb0cd602c35f8", 487 | "funded_month": "2005-02", 488 | "funded_quarter": "2005-Q1", 489 | "funded_year": 2005, 490 | "funded_at": "01/02/2005", 491 | "funding_round_type": "venture", 492 | "funding_round_code": "B", 493 | "raised_amount_usd": 13000000 494 | }, 495 | "type": "INVESTED_IN" 496 | } 497 | }, 498 | { 499 | "id": 15872, 500 | "source": 8609, 501 | "target": 7396, 502 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 503 | "data": { 504 | "properties": { 505 | "permalink": "/funding-round/ced5cae2e3eaa45a855906a8ded3c176", 506 | "funded_month": "2006-05", 507 | "funded_quarter": "2006-Q2", 508 | "funded_year": 2006, 509 | "funded_at": "01/05/2006", 510 | "funding_round_type": "venture", 511 | "funding_round_code": "C", 512 | "raised_amount_usd": 40000000 513 | }, 514 | "type": "INVESTED_IN" 515 | } 516 | }, 517 | { 518 | "id": 15880, 519 | "source": 3388, 520 | "target": 7396, 521 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 522 | "data": { 523 | "properties": { 524 | "permalink": "/funding-round/ced5cae2e3eaa45a855906a8ded3c176", 525 | "funded_month": "2006-05", 526 | "funded_quarter": "2006-Q2", 527 | "funded_year": 2006, 528 | "funded_at": "01/05/2006", 529 | "funding_round_type": "venture", 530 | "funding_round_code": "C", 531 | "raised_amount_usd": 40000000 532 | }, 533 | "type": "INVESTED_IN" 534 | } 535 | }, 536 | { 537 | "id": 15894, 538 | "source": 9209, 539 | "target": 7396, 540 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 541 | "data": { 542 | "properties": { 543 | "permalink": "/funding-round/ced5cae2e3eaa45a855906a8ded3c176", 544 | "funded_month": "2006-05", 545 | "funded_quarter": "2006-Q2", 546 | "funded_year": 2006, 547 | "funded_at": "01/05/2006", 548 | "funding_round_type": "venture", 549 | "funding_round_code": "C", 550 | "raised_amount_usd": 40000000 551 | }, 552 | "type": "INVESTED_IN" 553 | } 554 | }, 555 | { 556 | "id": 15902, 557 | "source": 8666, 558 | "target": 7396, 559 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 560 | "data": { 561 | "properties": { 562 | "permalink": "/funding-round/ced5cae2e3eaa45a855906a8ded3c176", 563 | "funded_month": "2006-05", 564 | "funded_quarter": "2006-Q2", 565 | "funded_year": 2006, 566 | "funded_at": "01/05/2006", 567 | "funding_round_type": "venture", 568 | "funding_round_code": "C", 569 | "raised_amount_usd": 40000000 570 | }, 571 | "type": "INVESTED_IN" 572 | } 573 | }, 574 | { 575 | "id": 16539, 576 | "source": 9967, 577 | "target": 6870, 578 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 579 | "data": { 580 | "properties": { 581 | "permalink": "/funding-round/2b54de2cc89a2f88e491d7261d47a132", 582 | "funded_month": "2006-09", 583 | "funded_quarter": "2006-Q3", 584 | "funded_year": 2006, 585 | "funded_at": "15/09/2006", 586 | "funding_round_type": "venture", 587 | "raised_amount_usd": 10000000 588 | }, 589 | "type": "INVESTED_IN" 590 | } 591 | }, 592 | { 593 | "id": 16542, 594 | "source": 8666, 595 | "target": 6870, 596 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 597 | "data": { 598 | "properties": { 599 | "permalink": "/funding-round/2b54de2cc89a2f88e491d7261d47a132", 600 | "funded_month": "2006-09", 601 | "funded_quarter": "2006-Q3", 602 | "funded_year": 2006, 603 | "funded_at": "15/09/2006", 604 | "funding_round_type": "venture", 605 | "raised_amount_usd": 10000000 606 | }, 607 | "type": "INVESTED_IN" 608 | } 609 | }, 610 | { 611 | "id": 17825, 612 | "source": 8609, 613 | "target": 7396, 614 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 615 | "data": { 616 | "properties": { 617 | "permalink": "/funding-round/58befe24f37e5202a2459fbb2b0b4292", 618 | "funded_month": "2007-05", 619 | "funded_quarter": "2007-Q2", 620 | "funded_year": 2007, 621 | "funded_at": "01/05/2007", 622 | "funding_round_type": "venture", 623 | "funding_round_code": "D", 624 | "raised_amount_usd": 45000000 625 | }, 626 | "type": "INVESTED_IN" 627 | } 628 | }, 629 | { 630 | "id": 17855, 631 | "source": 9209, 632 | "target": 7396, 633 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 634 | "data": { 635 | "properties": { 636 | "permalink": "/funding-round/58befe24f37e5202a2459fbb2b0b4292", 637 | "funded_month": "2007-05", 638 | "funded_quarter": "2007-Q2", 639 | "funded_year": 2007, 640 | "funded_at": "01/05/2007", 641 | "funding_round_type": "venture", 642 | "funding_round_code": "D", 643 | "raised_amount_usd": 45000000 644 | }, 645 | "type": "INVESTED_IN" 646 | } 647 | }, 648 | { 649 | "id": 17863, 650 | "source": 8666, 651 | "target": 7396, 652 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 653 | "data": { 654 | "properties": { 655 | "permalink": "/funding-round/58befe24f37e5202a2459fbb2b0b4292", 656 | "funded_month": "2007-05", 657 | "funded_quarter": "2007-Q2", 658 | "funded_year": 2007, 659 | "funded_at": "01/05/2007", 660 | "funding_round_type": "venture", 661 | "funding_round_code": "D", 662 | "raised_amount_usd": 45000000 663 | }, 664 | "type": "INVESTED_IN" 665 | } 666 | }, 667 | { 668 | "id": 19654, 669 | "source": 8666, 670 | "target": 7396, 671 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 672 | "data": { 673 | "properties": { 674 | "permalink": "/funding-round/f87a758df44c70ae7a8b63ed120cc5f5", 675 | "funded_month": "2008-02", 676 | "funded_quarter": "2008-Q1", 677 | "funded_year": 2008, 678 | "funded_at": "08/02/2008", 679 | "funding_round_type": "venture", 680 | "funding_round_code": "E", 681 | "raised_amount_usd": 40000000 682 | }, 683 | "type": "INVESTED_IN" 684 | } 685 | }, 686 | { 687 | "id": 21109, 688 | "source": 8609, 689 | "target": 6870, 690 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 691 | "data": { 692 | "properties": { 693 | "permalink": "/funding-round/d4c07cd9dea278ca2bbf1d208cd52094", 694 | "funded_month": "2008-10", 695 | "funded_quarter": "2008-Q4", 696 | "funded_year": 2008, 697 | "funded_at": "30/10/2008", 698 | "funding_round_type": "venture", 699 | "funding_round_code": "D", 700 | "raised_amount_usd": 30000000 701 | }, 702 | "type": "INVESTED_IN" 703 | } 704 | }, 705 | { 706 | "id": 21110, 707 | "source": 2982, 708 | "target": 6870, 709 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 710 | "data": { 711 | "properties": { 712 | "permalink": "/funding-round/d4c07cd9dea278ca2bbf1d208cd52094", 713 | "funded_month": "2008-10", 714 | "funded_quarter": "2008-Q4", 715 | "funded_year": 2008, 716 | "funded_at": "30/10/2008", 717 | "funding_round_type": "venture", 718 | "funding_round_code": "D", 719 | "raised_amount_usd": 30000000 720 | }, 721 | "type": "INVESTED_IN" 722 | } 723 | }, 724 | { 725 | "id": 22096, 726 | "source": 9967, 727 | "target": 7396, 728 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 729 | "data": { 730 | "properties": { 731 | "permalink": "/funding-round/5a5a9348b63a488cdddbb8a6575011cf", 732 | "funded_month": "2009-05", 733 | "funded_quarter": "2009-Q2", 734 | "funded_year": 2009, 735 | "funded_at": "19/05/2009", 736 | "funding_round_type": "venture", 737 | "funding_round_code": "F", 738 | "raised_amount_usd": 50000000 739 | }, 740 | "type": "INVESTED_IN" 741 | } 742 | }, 743 | { 744 | "id": 23883, 745 | "source": 8609, 746 | "target": 6870, 747 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 748 | "data": { 749 | "properties": { 750 | "permalink": "/funding-round/16be99d5a6d00fa4b96ad491f2c77a6b", 751 | "funded_month": "2010-01", 752 | "funded_quarter": "2010-Q1", 753 | "funded_year": 2010, 754 | "funded_at": "25/01/2010", 755 | "funding_round_type": "venture", 756 | "funding_round_code": "E", 757 | "raised_amount_usd": 24000000 758 | }, 759 | "type": "INVESTED_IN" 760 | } 761 | }, 762 | { 763 | "id": 25044, 764 | "source": 8609, 765 | "target": 6870, 766 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 767 | "data": { 768 | "properties": { 769 | "permalink": "/funding-round/4e78a6ae11fc590f15aa8c46c044d6c8", 770 | "funded_month": "2010-07", 771 | "funded_quarter": "2010-Q3", 772 | "funded_year": 2010, 773 | "funded_at": "14/07/2010", 774 | "funding_round_type": "private_equity", 775 | "raised_amount_usd": 21500000 776 | }, 777 | "type": "INVESTED_IN" 778 | } 779 | }, 780 | { 781 | "id": 28176, 782 | "source": 3388, 783 | "target": 6870, 784 | "attributes": { "color": "#54aef3", "width": 5, "text": "INVESTED_IN" }, 785 | "data": { 786 | "properties": { 787 | "permalink": "/funding-round/519d19ee82f49c43ca377a2f359f5da6", 788 | "funded_month": "2011-06", 789 | "funded_quarter": "2011-Q2", 790 | "funded_year": 2011, 791 | "funded_at": "14/06/2011", 792 | "funding_round_type": "private_equity", 793 | "raised_amount_usd": 280000000 794 | }, 795 | "type": "INVESTED_IN" 796 | } 797 | }, 798 | { 799 | "id": 30380, 800 | "source": 9209, 801 | "target": 6870, 802 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 803 | "data": { 804 | "properties": { 805 | "permalink": "/funding-round/d38d9c2ff343a584a705f085b04d7749", 806 | "funded_month": "2012-01", 807 | "funded_quarter": "2012-Q1", 808 | "funded_year": 2012, 809 | "funded_at": "01/01/2012", 810 | "funding_round_type": "private_equity", 811 | "raised_amount_usd": 66000000 812 | }, 813 | "type": "INVESTED_IN" 814 | } 815 | }, 816 | { 817 | "id": 31066, 818 | "source": 9209, 819 | "target": 6870, 820 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 821 | "data": { 822 | "properties": { 823 | "permalink": "/funding-round/3aff9d7ae2c80037e00ea6a6eb4ca1f8", 824 | "funded_month": "2012-02", 825 | "funded_quarter": "2012-Q1", 826 | "funded_year": 2012, 827 | "funded_at": "29/02/2012", 828 | "funding_round_type": "private_equity", 829 | "raised_amount_usd": 81000000 830 | }, 831 | "type": "INVESTED_IN" 832 | } 833 | }, 834 | { 835 | "id": 32532, 836 | "source": 10229, 837 | "target": 6870, 838 | "attributes": { "color": "#54aef3", "width": 5, "text": "INVESTED_IN" }, 839 | "data": { 840 | "properties": { 841 | "permalink": "/funding-round/d549901c2adc09017bcd433cbeace96c", 842 | "funded_month": "2012-06", 843 | "funded_quarter": "2012-Q2", 844 | "funded_year": 2012, 845 | "funded_at": "14/06/2012", 846 | "funding_round_type": "private_equity", 847 | "raised_amount_usd": 250000000 848 | }, 849 | "type": "INVESTED_IN" 850 | } 851 | }, 852 | { 853 | "id": 47078, 854 | "source": 6870, 855 | "target": 8372, 856 | "attributes": { "color": "grey", "width": 1, "text": "ACQUIRED" }, 857 | "data": { 858 | "properties": { 859 | "acquisition_date": "09/10/2013", 860 | "year": "2013", 861 | "quarter": "2013-Q4", 862 | "currency": "USD", 863 | "month": "2013-10", 864 | "price_amount": 158000000 865 | }, 866 | "type": "ACQUIRED" 867 | } 868 | }, 869 | { 870 | "id": 12100, 871 | "source": 6866, 872 | "target": 124, 873 | "attributes": { "color": "grey", "width": 1, "text": "HAS_MARKET" }, 874 | "data": { "type": "HAS_MARKET" } 875 | }, 876 | { 877 | "id": 23445, 878 | "source": 10229, 879 | "target": 7163, 880 | "attributes": { "color": "#132b43", "width": 1, "text": "INVESTED_IN" }, 881 | "data": { 882 | "properties": { 883 | "permalink": "/funding-round/11d587bca7dc8bb2ccce0394cede3a2e", 884 | "funded_month": "2009-12", 885 | "funded_quarter": "2009-Q4", 886 | "funded_year": 2009, 887 | "funded_at": "15/12/2009", 888 | "funding_round_type": "debt_financing", 889 | "raised_amount_usd": 90000000 890 | }, 891 | "type": "INVESTED_IN" 892 | } 893 | }, 894 | { 895 | "id": 24428, 896 | "source": 10229, 897 | "target": 6866, 898 | "attributes": { "color": "#326896", "width": 3, "text": "INVESTED_IN" }, 899 | "data": { 900 | "properties": { 901 | "permalink": "/funding-round/bed3d41fcc95dac86db69bab58c4e524", 902 | "funded_month": "2010-04", 903 | "funded_quarter": "2010-Q2", 904 | "funded_year": 2010, 905 | "funded_at": "27/04/2010", 906 | "funding_round_type": "venture", 907 | "funding_round_code": "C", 908 | "raised_amount_usd": 115000000 909 | }, 910 | "type": "INVESTED_IN" 911 | } 912 | }, 913 | { 914 | "id": 10853, 915 | "source": 5418, 916 | "target": 124, 917 | "attributes": { "color": "grey", "width": 1, "text": "HAS_MARKET" }, 918 | "data": { "type": "HAS_MARKET" } 919 | }, 920 | { 921 | "id": 46713, 922 | "source": 2982, 923 | "target": 5418, 924 | "attributes": { "color": "grey", "width": 1, "text": "ACQUIRED" }, 925 | "data": { 926 | "properties": { 927 | "acquisition_date": "03/03/2009", 928 | "year": "2009", 929 | "quarter": "2009-Q1", 930 | "currency": "USD", 931 | "month": "2009-03", 932 | "price_amount": 400000000 933 | }, 934 | "type": "ACQUIRED" 935 | } 936 | } 937 | ] 938 | } 939 | -------------------------------------------------------------------------------- /demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import OgmaLib, { 2 | Edge, 3 | Node, 4 | Point, 5 | RawGraph, 6 | NodeGrouping as NodeGroupingTransformation, 7 | NodeAttributesValue 8 | } from "@linkurious/ogma"; 9 | import { morningBreeze } from "@linkurious/ogma-styles"; 10 | import { useEffect, useState, createRef, useCallback, useMemo } from "react"; 11 | import { LoadingOverlay } from "./components/LoadingOverlay"; 12 | // for geo mode 13 | import * as L from "leaflet"; 14 | // components 15 | import { 16 | Ogma, 17 | NodeStyleRule, 18 | EdgeStyleRule, 19 | StyleClass, 20 | Tooltip, 21 | NodeGrouping, 22 | Popup, 23 | Geo, 24 | NodeGroupingProps, 25 | useEvent, 26 | Theme 27 | } from "../../src"; 28 | 29 | // custom components: 30 | // layout component, to be applied on certain events 31 | import { LayoutService } from "./components/Layout"; 32 | // outlines canvas layer with halos 33 | import { GraphOutlines } from "./components/GraphOutlines"; 34 | // control panel 35 | import { Controls } from "./components/Controls"; 36 | import { MousePosition } from "./components/MousePosition"; 37 | import { Logo } from "./components/Logo"; 38 | 39 | // to enable geo mode integration 40 | OgmaLib.libraries["leaflet"] = L; 41 | 42 | type ND = unknown; 43 | type ED = unknown; 44 | 45 | export default function App() { 46 | // graph state 47 | const [graph, setGraph] = useState(); 48 | const [loading, setLoading] = useState(true); 49 | 50 | // UI states 51 | const [popupOpen, setPopupOpen] = useState(false); 52 | const [clickedNode, setClickedNode] = useState(); 53 | 54 | // ogma instance and grouping references 55 | const ogmaInstanceRef = createRef(); 56 | const groupingRef = createRef>(); 57 | 58 | // grouping and geo states 59 | const [nodeGrouping, setNodeGrouping] = useState(true); 60 | const [geoEnabled, setGeoEnabled] = useState(false); 61 | // styling states 62 | const [nodeSize, setNodeSize] = useState(5); 63 | const [edgeWidth, setEdgeWidth] = useState(0.5); 64 | const [groupingOptions] = useState>({ 65 | groupIdFunction: (node) => { 66 | const categories = node.getData("categories"); 67 | if (!categories) return undefined; 68 | return categories[0] === "INVESTOR" ? "INVESTOR" : undefined; 69 | }, 70 | nodeGenerator: (nodes) => { 71 | return { data: { multiplier: nodes.size } }; 72 | }, 73 | disabled: true 74 | }); 75 | const [useClass, setUseClass] = useState(false); 76 | 77 | // UI layers 78 | const [outlines, setOutlines] = useState(false); 79 | const [tooltipPositon, setTooltipPosition] = useState({ 80 | x: 0, 81 | y: 0 82 | }); 83 | const [target, setTarget] = useState(); 84 | 85 | const requestSetTooltipPosition = useCallback((pos: Point) => { 86 | requestAnimationFrame(() => setTooltipPosition(pos)); 87 | }, []); 88 | 89 | const popupPosition = useCallback( 90 | () => (clickedNode ? clickedNode.getPosition() : null), 91 | [clickedNode] 92 | ); 93 | const onPopupClose = useCallback(() => setPopupOpen(false), []); 94 | 95 | // load the graph 96 | useEffect(() => { 97 | setLoading(true); 98 | fetch("data.json") 99 | .then((res) => res.json()) 100 | .then((data: RawGraph) => { 101 | setGraph(data); 102 | setLoading(false); 103 | }); 104 | }, []); 105 | 106 | const onClick = useEvent("click", ({ target }) => { 107 | if (target && target.isNode) { 108 | setClickedNode(target); 109 | setPopupOpen(true); 110 | } 111 | }); 112 | 113 | const onMousemove = useEvent("mousemove", () => { 114 | if (!ogmaInstanceRef.current) return; 115 | const ptr = ogmaInstanceRef.current.getPointerInformation(); 116 | requestSetTooltipPosition( 117 | ogmaInstanceRef.current.view.screenToGraphCoordinates({ 118 | x: ptr.x, 119 | y: ptr.y 120 | }) 121 | ); 122 | setTarget(ptr.target); 123 | }); 124 | 125 | const onAddNodes = useEvent("addNodes", () => { 126 | if (!ogmaInstanceRef.current) return; 127 | ogmaInstanceRef.current.view.locateGraph({ duration: 250, padding: 50 }); 128 | }); 129 | 130 | const onReady = useCallback((instance: OgmaLib) => { 131 | ogmaInstanceRef.current = instance; 132 | }, []); 133 | 134 | const addNode = useCallback(() => { 135 | if (!ogmaInstanceRef.current) return; 136 | const size = ogmaInstanceRef.current.getNodes().size; 137 | const node = ogmaInstanceRef.current.addNode({ 138 | id: size 139 | }); 140 | if (size % 2) node.addClass("class"); 141 | }, []); 142 | 143 | const styleClassNodeAttributes = useMemo>(() => { 144 | console.log("Creating styleClassNodeAttributes"); 145 | return { 146 | shape: "diamond", 147 | color: (node) => { 148 | const categories: string[] = node.getData("categories"); 149 | if (!categories) return "#000000"; 150 | return categories.includes("INVESTOR") ? "#FF0000" : "#00FF00"; 151 | }, 152 | halo: { 153 | width: 0 154 | } 155 | }; 156 | }, []); // memoize the style class props 157 | 158 | // nothing to render yet 159 | if (loading) return ; 160 | 161 | return ( 162 |
163 | 164 | } 172 | > 173 | {/* Styling */} 174 | 175 | (n?.getData("multiplier") || 1) * nodeSize, // the label is the value os the property name. 178 | text: { 179 | content: (node) => node?.getData("properties.name"), 180 | font: "IBM Plex Sans", 181 | minVisibleSize: 3 182 | } 183 | }} 184 | /> 185 | 186 | {useClass && ( 187 | 188 | )} 189 | 190 | {/* Layout */} 191 | 192 | 193 | {/* Grouping */} 194 | 200 | 201 | {/* context-aware UI */} 202 | 207 | {!!clickedNode && ( 208 |
{`Node ${clickedNode.getId()}:`}
209 | )} 210 |
211 | 216 |
217 | {target 218 | ? `${target.isNode ? "Node" : "Edge"} #${target.getId()}` 219 | : "nothing"} 220 |
221 |
222 | 223 | 224 | {/* Geo mode */} 225 | 230 | 231 |
232 | setNodeGrouping(value)} 234 | nodeGrouping={nodeGrouping} 235 | setNodeSize={setNodeSize} 236 | setEdgeWidth={setEdgeWidth} 237 | outlines={outlines} 238 | setOutlines={setOutlines} 239 | geoEnabled={geoEnabled} 240 | setGeoEnabled={setGeoEnabled} 241 | addNode={addNode} 242 | useClass={useClass} 243 | setUseClass={setUseClass} 244 | /> 245 |
246 | ); 247 | } 248 | -------------------------------------------------------------------------------- /demo/src/components/Controls.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Drawer } from "./Drawer"; 3 | import { Toggle } from "./Toggle"; 4 | import { Slider } from "./Slider"; 5 | import { Menu as MenuIcon } from "react-feather"; 6 | 7 | interface ControlsProps { 8 | toggleNodeGrouping: (value: boolean) => void; 9 | nodeGrouping: boolean; 10 | setNodeSize: (value: number) => void; 11 | setEdgeWidth: (value: number) => void; 12 | outlines: boolean; 13 | setOutlines: (value: boolean) => void; 14 | geoEnabled: boolean; 15 | setGeoEnabled: (value: boolean) => void; 16 | addNode: () => void; 17 | useClass: boolean; 18 | setUseClass: (value: boolean) => void; 19 | } 20 | 21 | export function Controls({ 22 | toggleNodeGrouping, 23 | nodeGrouping, 24 | setNodeSize, 25 | setEdgeWidth, 26 | geoEnabled, 27 | setGeoEnabled, 28 | outlines, 29 | setOutlines, 30 | addNode, 31 | useClass, 32 | setUseClass 33 | }: ControlsProps) { 34 | //const ogma = useOgma(); 35 | const [drawerShown, setDrawerShown] = useState(false); 36 | const [nodeSize, setNodeSizeLocal] = useState(5); 37 | const [edgeWidth, setEdgeWidthLocal] = useState(0.25); 38 | return ( 39 | <> 40 |
41 | {!drawerShown && ( 42 | 48 | )} 49 |
50 | setDrawerShown(false)} 53 | className="controls" 54 | > 55 |

Controls

56 |
57 | toggleNodeGrouping(!nodeGrouping)} 61 | /> 62 |
63 |
64 |

Node size

65 | { 71 | setNodeSize(value); 72 | setNodeSizeLocal(value); 73 | }} 74 | /> 75 |
76 |
77 |

Edge width

78 | { 84 | setEdgeWidth(value); 85 | setEdgeWidthLocal(value); 86 | }} 87 | /> 88 |
89 |
90 | setOutlines(!outlines)} 93 | label="Node outlines" 94 | /> 95 |
96 |
97 | setGeoEnabled(!geoEnabled)} 100 | label="Geo mode" 101 | /> 102 |
103 |
104 |
addNode()}> 105 | Add node 106 |
107 |
108 |
109 | 114 |
115 |
116 | 117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /demo/src/components/Drawer.css: -------------------------------------------------------------------------------- 1 | .drawer { 2 | position: fixed; 3 | top: 0; 4 | right: 0; 5 | height: 100%; 6 | width: 300px; 7 | transform: translateX(100%); 8 | transition: transform 0.3s ease-in-out; 9 | background-color: white; 10 | z-index: 1001; 11 | } 12 | 13 | .drawer.open { 14 | transform: translateX(0); 15 | } 16 | 17 | .drawer-content { 18 | padding: 20px; 19 | } 20 | 21 | .close-button { 22 | position: absolute; 23 | top: 10px; 24 | right: 10px; 25 | background: none; 26 | border: none; 27 | font-size: 24px; 28 | cursor: pointer; 29 | } 30 | 31 | .drawer-underlay { 32 | position: fixed; 33 | top: 0; 34 | left: 0; 35 | width: 100%; 36 | height: 100%; 37 | background-color: rgba(0, 0, 0, 0); 38 | transition: background-color 0.3s ease-in-out; 39 | z-index: 1000; 40 | } 41 | 42 | .drawer-underlay.show { 43 | background-color: rgba(0, 0, 0, 0.25); 44 | } 45 | -------------------------------------------------------------------------------- /demo/src/components/Drawer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import "./Drawer.css"; 3 | 4 | interface DrawerProps { 5 | isOpen: boolean; 6 | children: React.ReactNode; 7 | onClosed?: () => void; 8 | className?: string; 9 | } 10 | 11 | export const Drawer: React.FC = ({ 12 | isOpen, 13 | children, 14 | onClosed, 15 | className, 16 | }) => { 17 | const [isDrawerOpen, setIsDrawerOpen] = useState(isOpen); 18 | 19 | useEffect(() => { 20 | setIsDrawerOpen(isOpen); 21 | }, [isOpen]); 22 | 23 | const closeDrawer = () => { 24 | setIsDrawerOpen(false); 25 | if (onClosed) onClosed(); 26 | }; 27 | 28 | return ( 29 | <> 30 | {isDrawerOpen && ( 31 |
35 | )} 36 |
39 | 42 |
{children}
43 |
44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /demo/src/components/GraphOutlines.tsx: -------------------------------------------------------------------------------- 1 | import { CanvasLayer as OgmaCanvasLayer } from "@linkurious/ogma"; 2 | import { useEffect, createRef, useCallback } from "react"; 3 | import { useOgma, CanvasLayer } from "../../../src"; 4 | 5 | interface GraphOutlinesProps { 6 | visible: boolean; 7 | } 8 | 9 | export function GraphOutlines({ visible = true }: GraphOutlinesProps) { 10 | const ogma = useOgma(); 11 | const layerRef = createRef(); 12 | 13 | const render = useCallback((ctx: CanvasRenderingContext2D) => { 14 | ctx.fillStyle = "rgba(157, 197, 187, 0.25)"; 15 | ctx.beginPath(); 16 | ogma 17 | .getNodes() 18 | .getAttributes(["x", "y", "radius"]) 19 | .forEach(({ x, y, radius }) => { 20 | ctx.moveTo(x, y); 21 | ctx.arc(x, y, (radius as number) * 6, 0, 2 * Math.PI); 22 | }); 23 | ctx.fill(); 24 | }, []); 25 | 26 | useEffect(() => { 27 | const refresh = () => { 28 | layerRef.current?.refresh(); 29 | }; 30 | ogma.events.on(["nodesDragProgress", "idle"], refresh); 31 | return () => { 32 | ogma.events.off(refresh); 33 | }; 34 | }, []); 35 | 36 | return ( 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /demo/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { NodeList } from "@linkurious/ogma"; 2 | import { useCallback, useEffect } from "react"; 3 | import { useOgma } from "../../../src"; 4 | 5 | // custom layout service based on the event of the nodes being added 6 | export function LayoutService() { 7 | const ogma = useOgma(); 8 | const onNodesAdded = useCallback( 9 | (_evt: { nodes: NodeList }) => { 10 | ogma.events.once("idle", () => ogma.layouts.force({ locate: true })); 11 | }, 12 | [ogma] 13 | ); 14 | 15 | useEffect(() => { 16 | // register listener 17 | ogma.events.on( 18 | ["addNodes", "transformationEnabled", "transformationDisabled"], 19 | onNodesAdded 20 | ); 21 | 22 | // cleanup 23 | return () => { 24 | ogma.events.off(onNodesAdded); 25 | }; 26 | }, [ogma]); 27 | 28 | return null; 29 | } 30 | -------------------------------------------------------------------------------- /demo/src/components/LoadingOverlay.css: -------------------------------------------------------------------------------- 1 | .loading-overlay { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | background: rgba(255, 255, 255, 0.8); 8 | backdrop-filter: blur(5px); 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | z-index: 9999; 13 | } 14 | 15 | .loading-indicator { 16 | width: 50px; 17 | height: 50px; 18 | border: 5px solid rgba(0, 0, 0, 0.1); 19 | border-top: 5px solid var(--overlay-text-color); 20 | border-radius: 50%; 21 | animation: spin 1s linear infinite; 22 | } 23 | 24 | @keyframes spin { 25 | 0% { 26 | transform: rotate(0deg); 27 | } 28 | 100% { 29 | transform: rotate(360deg); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /demo/src/components/LoadingOverlay.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./LoadingOverlay.css"; 3 | 4 | export const LoadingOverlay: React.FC = () => { 5 | return ( 6 |
7 |
8 |
9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /demo/src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { GitHub as GithubIcon } from "react-feather"; 2 | import { Icon as ReactIcon } from "./ReactIcon"; 3 | 4 | export const Logo = () => { 5 | return ( 6 |
7 | Ogma + React 8 | 13 |
14 | 15 |
16 |
17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /demo/src/components/MousePosition.css: -------------------------------------------------------------------------------- 1 | .position-control__container { 2 | position: absolute; 3 | top: 15px; 4 | left: 20em; 5 | color: #888; 6 | padding: 0.5em; 7 | font-size: x-small; 8 | } 9 | -------------------------------------------------------------------------------- /demo/src/components/MousePosition.tsx: -------------------------------------------------------------------------------- 1 | import { Point, Layer as OgmaLayer } from "@linkurious/ogma"; 2 | import { useEffect, useState, useCallback, useRef } from "react"; 3 | import { useOgma, Layer } from "../../../src"; 4 | import "./MousePosition.css"; 5 | 6 | export const MousePosition = () => { 7 | const ogma = useOgma(); 8 | const [position, setPosition] = useState({ x: 0, y: 0 }); 9 | const layerRef = useRef(null); 10 | 11 | const requestSetPosition = useCallback( 12 | (pos: Point) => { 13 | requestAnimationFrame(() => setPosition(pos)); 14 | }, 15 | [setPosition] 16 | ); 17 | 18 | useEffect(() => { 19 | const listener = () => { 20 | const { x, y } = ogma.getPointerInformation(); 21 | requestSetPosition({ x, y }); 22 | }; 23 | ogma.events.on("mousemove", listener); 24 | return () => { 25 | ogma.events.off(listener); 26 | }; 27 | }, [ogma]); 28 | 29 | return ( 30 | 31 |
32 | {position.x}, {position.y} 33 |
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /demo/src/components/ReactIcon.tsx: -------------------------------------------------------------------------------- 1 | export const Icon = ({ width = 12, height = 12, ...props }) => ( 2 | 10 | 14 | 15 | ); 16 | -------------------------------------------------------------------------------- /demo/src/components/ShuffleIcon.tsx: -------------------------------------------------------------------------------- 1 | export const ShuffleIcon = ({ width = 12, height = 12 }) => { 2 | return ( 3 | 12 | 20 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /demo/src/components/Slider.css: -------------------------------------------------------------------------------- 1 | .range-slider { 2 | margin: 10px 0 0 0; 3 | --knob-size: 10px; 4 | --dark: #2c3e50; 5 | --label-color: #222; 6 | } 7 | 8 | .range-slider { 9 | width: 100%; 10 | } 11 | 12 | .range-slider__range { 13 | width: calc(100% - (73px)); 14 | height: 10px; 15 | border-radius: 5px; 16 | background: #d7dcdf; 17 | outline: none; 18 | padding: 0; 19 | margin: 0; 20 | } 21 | 22 | .range-slider__range::-webkit-slider-thumb { 23 | -webkit-appearance: none; 24 | appearance: none; 25 | width: var(--knob-size); 26 | height: var(--knob-size); 27 | border-radius: 50%; 28 | background: var(--dark); 29 | cursor: pointer; 30 | -webkit-transition: background 0.15s ease-in-out; 31 | transition: background 0.15s ease-in-out; 32 | } 33 | 34 | .range-slider__range::-webkit-slider-thumb:hover { 35 | background: var(--brand-color); 36 | } 37 | 38 | .range-slider__range:active::-webkit-slider-thumb { 39 | background: var(--brand-color); 40 | } 41 | 42 | .range-slider__range::-moz-range-thumb { 43 | width: var(--knob-size); 44 | height: var(--knob-size); 45 | border: 0; 46 | border-radius: 50%; 47 | background: var(--dark); 48 | cursor: pointer; 49 | -moz-transition: background 0.15s ease-in-out; 50 | transition: background 0.15s ease-in-out; 51 | } 52 | 53 | .range-slider__range::-moz-range-thumb:hover { 54 | background: --var(--brand-color); 55 | } 56 | 57 | .range-slider__range:active::-moz-range-thumb { 58 | background: --var(--brand-color); 59 | } 60 | .range-slider__range:focus::-webkit-slider-thumb { 61 | box-shadow: 62 | 0 0 0 3px #fff, 63 | 0 0 0 6px var(--brand-color); 64 | } 65 | 66 | .range-slider__value { 67 | display: inline-block; 68 | position: relative; 69 | width: 40px; 70 | color: #fff; 71 | line-height: 20px; 72 | font-size: 0.75em; 73 | text-align: center; 74 | border-radius: 3px; 75 | background: var(--label-color); 76 | padding: 5px 10px; 77 | margin-left: 8px; 78 | } 79 | 80 | .range-slider__value:after { 81 | position: absolute; 82 | top: 8px; 83 | left: -7px; 84 | width: 0; 85 | height: 0; 86 | border-top: 7px solid transparent; 87 | border-right: 7px solid var(--label-color); 88 | border-bottom: 7px solid transparent; 89 | content: ""; 90 | } 91 | 92 | ::-moz-range-track { 93 | background: #d7dcdf; 94 | border: 0; 95 | } 96 | 97 | input::-moz-focus-inner, 98 | input::-moz-focus-outer { 99 | border: 0; 100 | } 101 | -------------------------------------------------------------------------------- /demo/src/components/Slider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./Slider.css"; 3 | 4 | interface SliderProps { 5 | onChange?: (value: number) => void; 6 | max?: number; 7 | min?: number; 8 | value?: number; 9 | step?: number; 10 | } 11 | 12 | export const Slider: React.FC = ({ 13 | onChange, 14 | max = 0, 15 | min = 1, 16 | value = 0, 17 | step = 0.1, 18 | }) => { 19 | const handleChange = (event: React.ChangeEvent) => { 20 | if (typeof onChange === "function") onChange(Number(event.target.value)); 21 | }; 22 | 23 | return ( 24 |
25 | 34 | {value} 35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /demo/src/components/Toggle.css: -------------------------------------------------------------------------------- 1 | /* From Uiverse.io by arghyaBiswasDev */ 2 | /* The switch - the box around the slider */ 3 | .switch { 4 | font-size: 17px; 5 | position: relative; 6 | width: 100%; 7 | height: 2em; 8 | display: flex; 9 | align-items: center; 10 | } 11 | 12 | /* Hide default HTML checkbox */ 13 | .switch input { 14 | opacity: 0; 15 | width: 0; 16 | height: 0; 17 | display: none; 18 | display: none; 19 | } 20 | 21 | /* The slider */ 22 | .slider { 23 | position: relative; 24 | width: 40px; 25 | height: 20px; 26 | background-color: #ccc; 27 | border-radius: 20px; 28 | cursor: pointer; 29 | transition: background-color 0.2s; 30 | flex-shrink: 0; 31 | } 32 | 33 | .slider:before { 34 | content: ""; 35 | position: absolute; 36 | width: 18px; 37 | height: 18px; 38 | border-radius: 50%; 39 | background-color: white; 40 | top: 1px; 41 | left: 1px; 42 | transition: transform 0.2s; 43 | } 44 | 45 | input:checked + .slider { 46 | background-color: var(--brand-color); 47 | } 48 | 49 | input:focus + .slider { 50 | box-shadow: 0 0 1px #007bff; 51 | } 52 | 53 | input:checked + .slider:before { 54 | transform: translateX(20px); 55 | } 56 | 57 | .label-text { 58 | margin-left: 10px; 59 | } 60 | -------------------------------------------------------------------------------- /demo/src/components/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./Toggle.css"; 3 | 4 | interface ToggleProps { 5 | checked: boolean; 6 | onChange: (checked: boolean) => void; 7 | label?: string; 8 | className?: string; 9 | } 10 | 11 | export const Toggle: React.FC = ({ 12 | checked, 13 | onChange, 14 | label, 15 | className, 16 | }) => { 17 | const handleChange = React.useCallback( 18 | (evt: React.ChangeEvent) => { 19 | if (typeof onChange === "function") onChange(evt.target.checked); 20 | }, 21 | [onChange] 22 | ); 23 | 24 | return ( 25 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | -webkit-font-smoothing: antialiased; 4 | -moz-osx-font-smoothing: grayscale; 5 | } 6 | 7 | :root { 8 | --overlay-background-color: #282c34; 9 | --text-color: #222222; 10 | --brand-color: #0094ff; 11 | --brand-color-darker: #0074cc; 12 | --overlay-text-color: var(--brand-color); 13 | --fontFamily: "IBM Plex Sans", sans-serif; 14 | --border-radius: 5px; 15 | } 16 | 17 | html, 18 | body { 19 | height: 100%; 20 | font-family: var(--fontFamily); 21 | } 22 | 23 | #root { 24 | height: 100%; 25 | } 26 | 27 | code { 28 | font-family: 29 | source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; 30 | } 31 | 32 | .App { 33 | text-align: center; 34 | width: 100%; 35 | height: 100%; 36 | min-height: 100vh; 37 | } 38 | 39 | .Logo { 40 | position: absolute; 41 | top: 15px; 42 | left: 15px; 43 | z-index: 401; 44 | display: flex; 45 | backdrop-filter: blur(5px); 46 | } 47 | 48 | .ogma-tooltip, 49 | .ogma-popup { 50 | z-index: 401; 51 | box-sizing: border-box; 52 | } 53 | 54 | .ogma-tooltip--content, 55 | .ogma-popup--body { 56 | transform: translate(-50%, 0); 57 | background-color: var(--overlay-background-color); 58 | color: var(--overlay-text-color); 59 | border-radius: 5px; 60 | padding: 5px; 61 | box-sizing: border-box; 62 | box-shadow: 0 8px 30px rgb(0 0 0 / 12%); 63 | width: auto; 64 | height: auto; 65 | position: relative; 66 | } 67 | 68 | .ogma-tooltip { 69 | /* transition: linear; 70 | transition-property: transform; 71 | transition-duration: 50ms; */ 72 | pointer-events: none; 73 | } 74 | 75 | .ogma-popup--body { 76 | transform: translate(-50%, -100%); 77 | } 78 | 79 | .ogma-tooltip--content:after, 80 | .ogma-popup--body:after { 81 | content: ""; 82 | width: 0; 83 | height: 0; 84 | border-style: solid; 85 | border-width: 6px 7px 6px 0; 86 | border-color: transparent var(--overlay-background-color) transparent 87 | transparent; 88 | position: absolute; 89 | left: 50%; 90 | top: auto; 91 | bottom: 3px; 92 | right: auto; 93 | transform: translate(-50%, 100%) rotate(270deg); 94 | } 95 | 96 | .ogma-popup--close { 97 | position: absolute; 98 | top: 0px; 99 | right: 5px; 100 | cursor: pointer; 101 | } 102 | 103 | .ogma-popup--top .ogma-popup--body, 104 | .ogma-tooltip--top .ogma-tooltip--content { 105 | bottom: 6px; 106 | transform: translate(-50%, -100%); 107 | } 108 | 109 | .ogma-popup--bottom .ogma-popup--body, 110 | .ogma-tooltip--bottom .ogma-tooltip--content { 111 | transform: translate(-50%, 0%); 112 | top: 3px; 113 | } 114 | 115 | .ogma-popup--bottom .ogma-popup--body:after, 116 | .ogma-tooltip--bottom .ogma-tooltip--content:after { 117 | top: 3px; 118 | bottom: auto; 119 | transform: translate(-50%, -100%) rotate(90deg); 120 | } 121 | 122 | .ogma-popup--right .ogma-popup--body, 123 | .ogma-tooltip--right .ogma-tooltip--content { 124 | transform: translate(0, -50%); 125 | left: 6px; 126 | } 127 | 128 | .ogma-popup--right .ogma-popup--body:after, 129 | .ogma-tooltip--right .ogma-tooltip--content:after { 130 | left: 0%; 131 | top: 50%; 132 | transform: translate(-100%, -50%) rotate(0deg); 133 | } 134 | 135 | .ogma-popup--left .ogma-popup--body, 136 | .ogma-tooltip--left .ogma-tooltip--content { 137 | transform: translate(-100%, -50%); 138 | right: 6px; 139 | } 140 | 141 | .ogma-popup--left .ogma-popup--body:after, 142 | .ogma-tooltip--left .ogma-tooltip--content:after { 143 | right: 0%; 144 | left: auto; 145 | top: 50%; 146 | transform: translate(100%, -50%) rotate(180deg); 147 | } 148 | 149 | .ogma-popup--content { 150 | padding: 10px; 151 | } 152 | 153 | .control-buttons { 154 | position: absolute; 155 | z-index: 400; 156 | right: 20px; 157 | top: 20px; 158 | } 159 | 160 | .control-buttons button { 161 | backdrop-filter: blur(5px); 162 | line-height: 1em; 163 | width: 2.5em; 164 | background-color: #fff; 165 | border: 1px solid #ccc; 166 | border-radius: var(--border-radius); 167 | cursor: pointer; 168 | } 169 | 170 | .control-buttons button:hover { 171 | border-color: var(--brand-color); 172 | color: var(--brand-color); 173 | } 174 | 175 | .controls-section { 176 | margin-bottom: 2em; 177 | } 178 | 179 | #button { 180 | position: absolute; 181 | left: 10px; 182 | top: 10px; 183 | z-index: 401; 184 | } 185 | 186 | .ReactIcon { 187 | margin: 3px; 188 | } 189 | 190 | .gh-link { 191 | margin-left: 1em; 192 | } 193 | 194 | .link-button { 195 | cursor: pointer; 196 | color: var(--brand-color); 197 | text-decoration: underline; 198 | text-align: left; 199 | } 200 | 201 | .link-button:hover, 202 | .link-button:focus, 203 | .link-button:active { 204 | color: var(--brand-color-darker); 205 | } 206 | -------------------------------------------------------------------------------- /demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { defineConfig } from "vite"; 4 | import react from "@vitejs/plugin-react"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | base: "/ogma-react/", 9 | plugins: [react()] 10 | }); 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@linkurious/ogma-react", 3 | "version": "5.1.6", 4 | "description": "A light adaptation of Ogma for React application", 5 | "keywords": [ 6 | "ogma", 7 | "react", 8 | "graph-visualisation", 9 | "webgl", 10 | "graph" 11 | ], 12 | "type": "module", 13 | "jsdelivr": "dist/index.mjs", 14 | "browser": "dist/index.umd.js", 15 | "main": "dist/index.mjs", 16 | "module": "dist/index.mjs", 17 | "typings": "dist/index.d.ts", 18 | "exports": { 19 | ".": { 20 | "types": "./dist/index.d.ts", 21 | "import": "./dist/index.mjs", 22 | "require": "./dist/index.cjs", 23 | "default": "./dist/index.mjs" 24 | } 25 | }, 26 | "files": [ 27 | "dist/*.d.ts", 28 | "dist/*.js", 29 | "dist/*.js.map", 30 | "dist/*.md" 31 | ], 32 | "publishConfig": { 33 | "access": "public" 34 | }, 35 | "scripts": { 36 | "tsc": "tsc", 37 | "start": "vite dev demo", 38 | "build": "npm ls @linkurious/ogma && npm run build:lib && npm run build:ts", 39 | "build:ts": "npm run typecheck && npm run types", 40 | "build:demo": "vite build demo", 41 | "build:lib": "vite build && npm run build:files", 42 | "build:files": "scripts/prepublish.mjs && cp README.md dist", 43 | "format": "prettier -w src/**/*.{ts,tsx} demo/**/*.{ts,tsx} test/**/*.{ts,tsx}", 44 | "test": "vitest", 45 | "test:unit": "vitest run --coverage --reporter=junit --reporter=default --outputFile reports/unit/junit-test-results.xml", 46 | "typecheck": "tsc --noEmit --skipLibCheck --project tsconfig.build.json", 47 | "types": "dts-bundle-generator -o ./dist/index.d.ts ./src/index.ts --no-banner", 48 | "postversion": "sync_versions", 49 | "doc:publish": "npm run build:demo && gh-pages -t --nojekyll -d demo/dist", 50 | "bump:patch": "npm version patch --no-git-tag-version" 51 | }, 52 | "prettier": { 53 | "trailingComma": "none" 54 | }, 55 | "peerDependencies": { 56 | "@linkurious/ogma": "^5.1.0", 57 | "react": ">=17.0.0", 58 | "react-dom": ">=17.0.0" 59 | }, 60 | "devDependencies": { 61 | "@linkurious/code-tools": "^0.0.15", 62 | "@linkurious/eslint-config-ogma": "^1.0.5", 63 | "@linkurious/ogma-styles": "^0.0.4", 64 | "@next/bundle-analyzer": "^14.0.0", 65 | "@testing-library/jest-dom": "^5.16.4", 66 | "@testing-library/react": "^16.2.0", 67 | "@testing-library/user-event": "^14.4.3", 68 | "@types/leaflet": "^1.7.9", 69 | "@types/lodash.throttle": "^4.1.6", 70 | "@types/node": "^20.0.0", 71 | "@types/react": "^19.0.0", 72 | "@types/react-dom": "^19.0.0", 73 | "@vitejs/plugin-react": "latest", 74 | "@vitest/coverage-v8": "latest", 75 | "@vitest/ui": "latest", 76 | "canvas": "^3.0.0", 77 | "dts-bundle-generator": "^9.0.0", 78 | "feather-icons": "^4.29.2", 79 | "gh-pages": "^6.0.0", 80 | "jsdom": "latest", 81 | "leaflet": "^1.8.0", 82 | "prettier": "^3.0.0", 83 | "react": "^19.0.0", 84 | "react-dom": "^19.0.0", 85 | "react-feather": "^2.0.10", 86 | "tslib": "^2.5.0", 87 | "typescript": "^5.3.2", 88 | "vite": "latest", 89 | "vitest": "latest" 90 | }, 91 | "repository": { 92 | "type": "git", 93 | "url": "git+https://github.com/linkurious/ogma-react.git" 94 | }, 95 | "author": "Linkurious SAS", 96 | "license": "Apache-2.0", 97 | "bugs": { 98 | "url": "https://github.com/linkurious/ogma-react/issues" 99 | }, 100 | "homepage": "https://github.com/linkurious/ogma-react#readme" 101 | } -------------------------------------------------------------------------------- /scripts/prepublish.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from "fs/promises"; 4 | 5 | fs.readFile("package.json", "utf8") 6 | .then((data) => { 7 | const base = JSON.parse(data); 8 | const overwrite = { 9 | devDependencies: undefined, 10 | scripts: undefined, 11 | eslintConfig: undefined, 12 | browserslist: undefined, 13 | jest: undefined, 14 | "jest-junit": undefined, 15 | peerDependencies: base.peerDependencies, 16 | private: undefined, 17 | files: base.files.map((f) => f.replace("dist/", "")), 18 | exports: { ".": {} }, 19 | }; 20 | ["jsdelivr", "browser", "main", "module", "typings"].forEach((field) => { 21 | overwrite[field] = base[field].replace("dist/", ""); 22 | }); 23 | const baseExports = base.exports["."]; 24 | Object.keys(baseExports).forEach((key) => { 25 | overwrite.exports["."][key] = baseExports[key].replace("./dist/", "./"); 26 | }); 27 | return { ...base, ...overwrite }; 28 | }) 29 | .then((data) => 30 | fs.writeFile("dist/package.json", JSON.stringify(data, null, 2)) 31 | ); 32 | -------------------------------------------------------------------------------- /src/context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, Context } from "react"; 2 | import OgmaLib from "@linkurious/ogma"; 3 | 4 | export function createOgmaContext() { 5 | return createContext<{ ogma?: OgmaLib } | null>(null); 6 | } 7 | 8 | export const OgmaContext = createContext(undefined) as Context< 9 | OgmaLib | undefined 10 | >; 11 | 12 | /** 13 | * This is the hook that allows you to access the Ogma instance. 14 | * It should only be used in the context of the `Ogma` component. 15 | */ 16 | export const useOgma = (): OgmaLib => { 17 | const ogma = useContext(OgmaContext); 18 | if (!ogma) throw new Error("useOgma must be used within an OgmaProvider"); 19 | return ogma; 20 | }; 21 | -------------------------------------------------------------------------------- /src/geo.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { GeoModeOptions } from "@linkurious/ogma"; 3 | import { useOgma } from "./context"; 4 | 5 | interface GeoModeProps extends GeoModeOptions { 6 | enabled?: boolean; 7 | } 8 | 9 | export function Geo({ enabled = false, ...options }: GeoModeProps) { 10 | const ogma = useOgma(); 11 | 12 | useEffect(() => { 13 | if (enabled) ogma.geo.enable(options); 14 | else ogma.geo.disable(); 15 | }, [enabled]); 16 | 17 | return null; 18 | } 19 | -------------------------------------------------------------------------------- /src/hooks/useEvent.ts: -------------------------------------------------------------------------------- 1 | import { EventTypes } from "@linkurious/ogma"; 2 | import { useCallback } from "react"; 3 | import { EventNames } from "../types"; 4 | 5 | export function useEvent< 6 | ND = unknown, 7 | ED = unknown, 8 | K extends EventNames = EventNames 9 | // @ts-expect-error evtName is used to infer the type of the event 10 | >(eventName: K, handler: (event: EventTypes[K]) => void, dependencies?: any[]) { 11 | const dep = dependencies ? dependencies : []; 12 | const callback = useCallback(handler, dep); 13 | 14 | return callback; 15 | } 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Ogma } from "./ogma"; 2 | export { useOgma } from "./context"; 3 | export { useEvent } from "./hooks/useEvent"; 4 | 5 | export * from "./styles"; 6 | export * from "./transformations"; 7 | export * from "./overlay"; 8 | export * from "./geo"; 9 | export * from "./types"; 10 | -------------------------------------------------------------------------------- /src/ogma.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useState, 3 | useEffect, 4 | useRef, 5 | useLayoutEffect, 6 | forwardRef, 7 | useImperativeHandle, 8 | ReactNode, 9 | Ref, 10 | memo 11 | } from "react"; 12 | import OgmaLib, { 13 | Options as OgmaOptions, 14 | RawGraph, 15 | EventTypes 16 | } from "@linkurious/ogma"; 17 | // import { Theme } from "@linkurious/ogma"; 18 | import { OgmaContext } from "./context"; 19 | import { 20 | EventHandlerProps, 21 | getEventNameFromProp, 22 | EventHandlers, 23 | forEachEventHandler, 24 | Theme 25 | } from "./types"; 26 | 27 | interface OgmaProps extends EventHandlerProps> { 28 | options?: Partial; 29 | onReady?: (ogma: OgmaLib) => void; 30 | graph?: RawGraph; 31 | children?: ReactNode; 32 | theme?: Theme; 33 | } 34 | 35 | const defaultOptions = {}; 36 | 37 | /** 38 | * Main component for the Ogma library. 39 | */ 40 | export const OgmaComponent = ( 41 | props: OgmaProps, 42 | ref?: Ref> 43 | ) => { 44 | const { options = defaultOptions, children, graph, onReady, theme } = props; 45 | const eventHandlersRef = useRef>({}); 46 | const [ready, setReady] = useState(false); 47 | const [ogma, setOgma] = useState(); 48 | const [container, setContainer] = useState(null); 49 | const [graphData, setGraphData] = useState>(); 50 | const [ogmaOptions, setOgmaOptions] = useState(defaultOptions); 51 | const [graphTheme, setGraphTheme] = useState>(); 52 | 53 | useImperativeHandle(ref, () => { 54 | return ogma as OgmaLib; 55 | }, [ogma]); 56 | 57 | useEffect(() => { 58 | if (!container) return; 59 | 60 | const instance = new OgmaLib({ 61 | container, 62 | graph, 63 | options 64 | }); 65 | if (theme) { 66 | setGraphTheme(theme); 67 | instance.styles.setTheme(theme); 68 | } 69 | 70 | setOgma(instance); 71 | setReady(true); 72 | 73 | // send the new instance to the parent component 74 | if (onReady) onReady(instance); 75 | }, [container]); 76 | 77 | // resize handler 78 | useLayoutEffect(() => { 79 | const updateSize = () => ogma?.view.forceResize(); 80 | updateSize(); 81 | 82 | window.addEventListener("resize", updateSize); 83 | return () => window.removeEventListener("resize", updateSize); 84 | }, []); 85 | 86 | useEffect(() => { 87 | if (!ogma) return; 88 | 89 | if (graph && graph !== graphData) { 90 | setGraphData(graph); 91 | ogma.setGraph(graph); 92 | } 93 | if (options && ogmaOptions !== options) { 94 | setOgmaOptions(options); 95 | ogma.setOptions(options); 96 | } 97 | }, [graph, options]); 98 | 99 | useEffect(() => { 100 | if (!ogma) return; 101 | 102 | if (theme && theme !== graphTheme) { 103 | setGraphTheme(theme); 104 | ogma.styles.setTheme(theme); 105 | } 106 | }, [theme]); 107 | 108 | // Set up event handlers whenever props change 109 | useEffect(() => { 110 | if (!ogma) return; 111 | 112 | // Get all current event handler props 113 | const currentEventHandlers: EventHandlers = {}; 114 | 115 | // Check all props for event handlers (onXxx) 116 | Object.keys(props).forEach((propName) => { 117 | if (!propName.startsWith("on")) return; 118 | const name = propName as keyof EventTypes; 119 | const eventName = getEventNameFromProp(name); 120 | const propValue = props[propName as keyof OgmaProps]; 121 | 122 | if (eventName && typeof propValue === "function") { 123 | // No type assertion needed, eventName is already verified 124 | currentEventHandlers[eventName] = propValue as ( 125 | event: EventTypes[NonNullable] 126 | ) => void; 127 | } 128 | }); 129 | 130 | // Remove handlers that are no longer present 131 | forEachEventHandler(eventHandlersRef.current, (eventName, handler) => { 132 | if (!currentEventHandlers[eventName]) { 133 | // Handler was removed 134 | ogma.events.off(handler); 135 | delete eventHandlersRef.current[eventName]; 136 | } 137 | }); 138 | 139 | // Add new handlers 140 | forEachEventHandler(currentEventHandlers, (eventName, handler) => { 141 | const existingHandler = eventHandlersRef.current[eventName]; 142 | 143 | // If handler changed, remove old one 144 | if (existingHandler && existingHandler !== handler) { 145 | ogma.events.off(existingHandler); 146 | } 147 | 148 | // If it's a new handler or changed handler, add it 149 | if (!existingHandler || existingHandler !== handler) { 150 | //console.log(555, "add handler", eventName, existingHandler === handler); 151 | ogma.events.on(eventName, handler); 152 | // @ts-expect-error type union 153 | eventHandlersRef.current[eventName] = handler; 154 | } 155 | }); 156 | }, [props]); 157 | 158 | return ( 159 |
setContainer(containerRef)} 162 | > 163 | {ogma && ( 164 | 165 | {ready && children} 166 | 167 | )} 168 |
169 | ); 170 | }; 171 | 172 | export const Ogma = memo(forwardRef(OgmaComponent)); 173 | -------------------------------------------------------------------------------- /src/overlay/canvas.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useState, 3 | useEffect, 4 | useImperativeHandle, 5 | forwardRef, 6 | Ref 7 | } from "react"; 8 | import { CanvasLayer as OgmaCanvasLayer } from "@linkurious/ogma"; 9 | import { useOgma } from "../context"; 10 | 11 | interface CanvasLayerProps { 12 | /** Rendering function */ 13 | render: (ctx: CanvasRenderingContext2D) => void; 14 | /** Whether or not the layer should be moved with the graph */ 15 | isStatic?: boolean; 16 | /** Avoid redraw */ 17 | noClear?: boolean; 18 | /** Layer index */ 19 | index?: number; 20 | /** Layer visibility */ 21 | visible?: boolean; 22 | } 23 | 24 | const CanvasLayerComponent = ( 25 | { 26 | noClear = false, 27 | isStatic = false, 28 | render, 29 | index, 30 | visible = true 31 | }: CanvasLayerProps, 32 | ref?: Ref 33 | ) => { 34 | const ogma = useOgma(); 35 | const [layer, setLayer] = useState(null); 36 | 37 | useImperativeHandle(ref, () => layer as OgmaCanvasLayer, [layer]); 38 | 39 | useEffect(() => { 40 | const newLayer = ogma.layers.addCanvasLayer( 41 | render, 42 | { isStatic, noClear }, 43 | index 44 | ); 45 | setLayer(newLayer); 46 | 47 | return () => { 48 | if (newLayer) { 49 | newLayer.destroy(); 50 | setLayer(null); 51 | } 52 | }; 53 | }, []); 54 | 55 | useEffect(() => { 56 | if (layer) { 57 | if (index !== undefined && isFinite(index)) layer.moveTo(index); 58 | if (visible) layer.show(); 59 | else layer.hide(); 60 | } 61 | }, [layer, index, visible]); 62 | 63 | return null; 64 | }; 65 | 66 | /** 67 | * A canvas layer that can be added to the Ogma instance. See the [Ogma documentation](https://doc.linkurio.us/ogma/latest/api.html#Ogma-layers-addCanvasLayer) for more information. 68 | * 69 | * Useful to perform drawings in sync with the view. In the drawing function you 70 | * are given the CanvasRenderingContext2D, that is automatically scaled and 71 | * translated to be in sync with the graph. So you can simply use graph 72 | * coordinates to draw shapes and text in it. See our "Layers" examples for 73 | * the code snippets. 74 | */ 75 | export const CanvasLayer = forwardRef(CanvasLayerComponent); 76 | -------------------------------------------------------------------------------- /src/overlay/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./popup"; 2 | export * from "./tooltip"; 3 | export * from "./canvas"; 4 | export * from "./overlay"; 5 | export * from "./layer"; 6 | -------------------------------------------------------------------------------- /src/overlay/layer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState, 4 | ReactNode, 5 | Ref, 6 | forwardRef, 7 | useImperativeHandle 8 | } from "react"; 9 | import { createPortal } from "react-dom"; 10 | import { Layer as OgmaLayer } from "@linkurious/ogma"; 11 | import { useOgma } from "../context"; 12 | 13 | export interface LayerProps { 14 | children?: ReactNode; 15 | /** Overlay container className */ 16 | className?: string; 17 | /** Layer index */ 18 | index?: number; 19 | } 20 | 21 | export const Layer = forwardRef( 22 | ({ children, className = "", index }: LayerProps, ref?: Ref) => { 23 | const ogma = useOgma(); 24 | const [layer, setLayer] = useState(null); 25 | 26 | useImperativeHandle(ref, () => layer as OgmaLayer, [layer]); 27 | 28 | useEffect(() => { 29 | const newElt = document.createElement("div"); 30 | newElt.className = className; 31 | 32 | const overlay = ogma.layers.addLayer(newElt, index); 33 | setLayer(overlay); 34 | 35 | return () => { 36 | if (layer) { 37 | layer.destroy(); 38 | setLayer(null); 39 | } 40 | }; 41 | }, []); 42 | 43 | useEffect(() => { 44 | if (layer) layer.element.className = className; 45 | }, [className]); 46 | 47 | if (!layer) return null; 48 | 49 | return createPortal(children, layer.element); 50 | } 51 | ); 52 | -------------------------------------------------------------------------------- /src/overlay/overlay.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState, 4 | ReactNode, 5 | Ref, 6 | forwardRef, 7 | useImperativeHandle 8 | } from "react"; 9 | 10 | import OgmaLib, { 11 | Overlay as OverlayLayer, 12 | Size, 13 | Point 14 | } from "@linkurious/ogma"; 15 | import { useOgma } from "../context"; 16 | import { getPosition } from "./utils"; 17 | import { createPortal } from "react-dom"; 18 | 19 | interface OverlayProps { 20 | /** Overlay position */ 21 | position: Point | ((ogma: OgmaLib) => Point | null); 22 | /** Overlay size */ 23 | size?: Size; 24 | children?: ReactNode; 25 | /** Overlay container className */ 26 | className?: string; 27 | /** Whether the overlay should be scaled with the graph */ 28 | scaled?: boolean; 29 | } 30 | 31 | const offScreenPos: Point = { x: -9999, y: -9999 }; 32 | 33 | // TODO: use props for these classes 34 | export const Overlay = forwardRef( 35 | ( 36 | { position, children, className = "", size, scaled }: OverlayProps, 37 | ref?: Ref 38 | ) => { 39 | const ogma = useOgma(); 40 | const [layer, setLayer] = useState(null); 41 | 42 | useImperativeHandle(ref, () => layer as OverlayLayer, [layer]); 43 | 44 | useEffect(() => { 45 | // register listener 46 | const pos = getPosition(position, ogma) || offScreenPos; 47 | const newElement = document.createElement("div"); 48 | newElement.className = className; 49 | // html = getContent(ogma, pos, undefined, children); 50 | 51 | const overlay = ogma.layers.addOverlay({ 52 | position: pos || offScreenPos, 53 | element: newElement, 54 | size: size || ({ width: "auto", height: "auto" } as any as Size), 55 | scaled 56 | }); 57 | 58 | setLayer(overlay); 59 | 60 | return () => { 61 | // unregister listener 62 | if (layer) { 63 | layer.destroy(); 64 | setLayer(null); 65 | } 66 | }; 67 | }, []); 68 | 69 | useEffect(() => { 70 | if (layer) { 71 | const pos = getPosition(position, ogma) || offScreenPos; 72 | if (className) layer.element.className = className; 73 | layer.setPosition(pos); 74 | } 75 | }, [position, className]); 76 | 77 | if (!layer) return null; 78 | 79 | return createPortal(children, layer.element); 80 | } 81 | ); 82 | -------------------------------------------------------------------------------- /src/overlay/popup.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState, 4 | ReactNode, 5 | ReactElement, 6 | Ref, 7 | forwardRef, 8 | useImperativeHandle 9 | } from "react"; 10 | 11 | import OgmaLib, { 12 | Overlay as OverlayLayer, 13 | Size, 14 | Point 15 | } from "@linkurious/ogma"; 16 | import { useOgma } from "../context"; 17 | import { 18 | getContent, 19 | getPosition, 20 | getContainerClass, 21 | getCloseButton 22 | } from "./utils"; 23 | import { noop } from "../utils"; 24 | import { Placement } from "./types"; 25 | import { createPortal } from "react-dom"; 26 | 27 | interface PopupProps { 28 | /** Overlay content */ 29 | content?: string | ReactElement; 30 | /** Overlay position */ 31 | position: Point | ((ogma: OgmaLib) => Point | null); 32 | /** Overlay size */ 33 | size?: Size; 34 | /** Open state, whether or not the overlay should be shown */ 35 | isOpen?: boolean; 36 | 37 | /** Close button */ 38 | closeButton?: ReactNode | null; 39 | /** Close callback */ 40 | onClose?: () => void; 41 | /** Overlay placement relative to the position */ 42 | placement?: Placement; 43 | /** Close on Escape key */ 44 | closeOnEsc?: boolean; 45 | /** Overlay container className */ 46 | popupClass?: string; 47 | /** Overlay content className */ 48 | contentClass?: string; 49 | /** Overlay body className */ 50 | popupBodyClass?: string; 51 | /** Close button className */ 52 | closeButtonClass?: string; 53 | 54 | children?: ReactNode; 55 | } 56 | 57 | const offScreenPos: Point = { x: -9999, y: -9999 }; 58 | 59 | // TODO: use props for these classes 60 | const POPUP_CONTENT_CLASS = "ogma-popup--content"; 61 | const POPUP_CLOSE_BUTTON_CLASS = "ogma-popup--close"; 62 | const POPUP_BODY_CLASS = "ogma-popup--body"; 63 | const POPUP_CLASS = "ogma-popup"; 64 | 65 | const PopupComponent = ( 66 | { 67 | content, 68 | position, 69 | children, 70 | isOpen = true, 71 | closeButton, 72 | onClose = noop, 73 | placement = "top", 74 | popupClass = POPUP_CLASS, 75 | closeButtonClass = POPUP_CLOSE_BUTTON_CLASS, 76 | contentClass = POPUP_CONTENT_CLASS, 77 | popupBodyClass = POPUP_BODY_CLASS, 78 | size, 79 | closeOnEsc = true 80 | }: PopupProps, 81 | ref?: Ref 82 | ) => { 83 | const ogma = useOgma(); 84 | const [layer, setLayer] = useState(null); 85 | 86 | useImperativeHandle(ref, () => layer as OverlayLayer, [layer]); 87 | 88 | useEffect(() => { 89 | let currentLayer: OverlayLayer | null = null; 90 | let onKeyDown: ((event: { code: number }) => void) | null = null; 91 | let onClick: ((event: MouseEvent) => void) | null = null; 92 | 93 | if (isOpen) { 94 | // register listener 95 | const pos = getPosition(position, ogma) || offScreenPos; 96 | // Create initial empty content container 97 | currentLayer = ogma.layers.addOverlay({ 98 | position: pos || offScreenPos, 99 | element: `
100 |
101 | ${getCloseButton(closeButton, closeButtonClass)} 102 |
103 |
104 |
`, 105 | size: size || { width: "auto", height: "auto" }, 106 | scaled: false 107 | }); 108 | 109 | onClick = (evt: MouseEvent) => { 110 | const closeButton = currentLayer?.element.querySelector( 111 | `.${closeButtonClass}` 112 | ) as Element; 113 | if (evt.target && closeButton.contains(evt.target as Node)) { 114 | evt.stopPropagation(); 115 | evt.preventDefault(); 116 | onClose(); 117 | } 118 | }; 119 | onKeyDown = ({ code }: { code: number }) => { 120 | if (code === 27) onClose(); 121 | }; 122 | 123 | if (closeOnEsc) ogma.events.on("keyup", onKeyDown); 124 | currentLayer.element.addEventListener("click", onClick); 125 | 126 | setLayer(currentLayer); 127 | // Update content if static content is provided 128 | if (content && !children) { 129 | const contentElement = currentLayer.element.querySelector( 130 | `.${contentClass}` 131 | ); 132 | const html = getContent(ogma, pos, content, children); 133 | if (contentElement) contentElement.innerHTML = html; 134 | } 135 | setLayer(currentLayer); 136 | } 137 | 138 | return () => { 139 | // unregister listener 140 | if (currentLayer) { 141 | if (onClick) currentLayer.element.removeEventListener("click", onClick); 142 | if (onKeyDown) ogma.events.off(onKeyDown); 143 | currentLayer.destroy(); 144 | setLayer(null); 145 | } 146 | }; 147 | }, [isOpen, ogma]); 148 | 149 | useEffect(() => { 150 | if (layer && layer.element) { 151 | const pos = getPosition(position, ogma) || offScreenPos; 152 | layer.setPosition(pos); 153 | 154 | // Update container classes 155 | layer.element.className = getContainerClass(popupClass, placement); 156 | 157 | // Only update static content if provided 158 | if (content && !children) { 159 | const contentElement = layer.element.querySelector(`.${contentClass}`); 160 | if (contentElement) { 161 | contentElement.innerHTML = typeof content === "string" ? content : ""; 162 | } 163 | } 164 | 165 | if (isOpen) layer.show(); 166 | else layer.hide(); 167 | } 168 | }, [layer, position, isOpen, placement, popupClass, content, children]); 169 | 170 | // Render children through portal if they exist, otherwise render nothing 171 | if (!layer || !isOpen) return null; 172 | 173 | const contentElement = layer.element.querySelector(`.${contentClass}`); 174 | if (!contentElement) return null; 175 | 176 | return children ? createPortal(children, contentElement) : null; 177 | }; 178 | 179 | /** 180 | * A popup component. 181 | * Use it to display information statically on top of your visualisation 182 | * or to display a modal dialog. 183 | */ 184 | export const Popup = forwardRef(PopupComponent); 185 | -------------------------------------------------------------------------------- /src/overlay/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import OgmaLib, { 2 | Point, 3 | Size, 4 | Overlay as OverlayLayer 5 | } from "@linkurious/ogma"; 6 | import { 7 | useEffect, 8 | useState, 9 | useRef, 10 | ReactNode, 11 | Ref, 12 | useImperativeHandle, 13 | forwardRef 14 | } from "react"; 15 | import { useOgma } from "../context"; 16 | import { Placement, Content } from "./types"; 17 | import { 18 | getAdjustedPlacement, 19 | getContainerClass, 20 | getContent, 21 | getPosition 22 | } from "./utils"; 23 | 24 | type PositionGetter = (ogma: OgmaLib) => Point | null; 25 | 26 | interface TooltipProps { 27 | /** Tooltip id */ 28 | id?: string; 29 | /** Tooltip position */ 30 | position: Point | PositionGetter; 31 | /** Tooltip content */ 32 | content?: Content; 33 | /** Tooltip size */ 34 | size?: Size; 35 | /** Tooltip visibility */ 36 | visible?: boolean; 37 | /** Tooltip placement relative to the position */ 38 | placement?: Placement; 39 | /** Tooltip container className */ 40 | tooltipClass?: string; 41 | 42 | children?: ReactNode; 43 | } 44 | 45 | const TooltipComponent = ( 46 | { 47 | tooltipClass = "ogma-tooltip", 48 | placement = "right", 49 | position, 50 | size = { width: "auto", height: "auto" } as any as Size, 51 | children, 52 | content, 53 | visible = true 54 | }: TooltipProps, 55 | ref?: Ref 56 | ) => { 57 | const ogma = useOgma(); 58 | const [layer, setLayer] = useState(); 59 | const [coords, setCoords] = useState(); 60 | const [html, setHtml] = useState(""); 61 | const [dimensions, setDimensions] = useState(); 62 | const raf = useRef(0); 63 | 64 | useImperativeHandle(ref, () => layer!, [layer]); 65 | 66 | // component is mounted 67 | useEffect(() => { 68 | const className = getContainerClass(tooltipClass, placement); 69 | const wrapperHtml = `
`; 70 | const newCoords = { x: -9999, y: -9999 }; 71 | setCoords(newCoords); 72 | const tooltip = ogma.layers.addOverlay({ 73 | position: newCoords, 74 | element: wrapperHtml, 75 | scaled: false, 76 | size 77 | }); 78 | setLayer(tooltip); 79 | return () => { 80 | tooltip.destroy(); 81 | }; 82 | }, []); 83 | 84 | // content or position has changed 85 | useEffect(() => { 86 | const newContent = getContent(ogma, coords!, content, children); 87 | if (layer) { 88 | if (newContent !== html) { 89 | layer.element.firstElementChild!.innerHTML = newContent; 90 | setHtml(newContent); 91 | setDimensions({ 92 | width: layer.element.offsetWidth, 93 | height: layer.element.offsetHeight 94 | }); 95 | } 96 | const newCoords = getPosition(position, ogma); 97 | if (coords !== newCoords) { 98 | setCoords(newCoords); 99 | } 100 | if (visible) layer.show(); 101 | else layer.hide(); 102 | } 103 | raf.current = requestAnimationFrame(() => { 104 | if (layer && layer.element && coords && dimensions) { 105 | layer.element.className = getContainerClass( 106 | tooltipClass, 107 | getAdjustedPlacement(coords, placement, dimensions, ogma) 108 | ); 109 | layer.setPosition(coords); // throttledSetPosition(coords); 110 | } 111 | }); 112 | 113 | return () => cancelAnimationFrame(raf.current as number); 114 | }, [children, content, position, visible]); 115 | 116 | return null; 117 | }; 118 | 119 | /** 120 | * Tooltip layer is a custom component to render some dynamic data on top of 121 | * your visualisation. The position and contents can be changed quickly and it 122 | * will adapt the placement to the viewport size. See in in action in our 123 | * [example](linkurious.github.io/ogma-react/) 124 | */ 125 | export const Tooltip = forwardRef(TooltipComponent); 126 | -------------------------------------------------------------------------------- /src/overlay/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import OgmaLib, { Point } from "@linkurious/ogma"; 3 | 4 | export type Placement = "top" | "bottom" | "left" | "right" | "center"; 5 | 6 | export type PositionGetter = (ogma: OgmaLib) => Point | null; 7 | 8 | export type Content = 9 | | string 10 | | ReactElement 11 | | ((ogma: OgmaLib, position: Point | null) => ReactElement); 12 | -------------------------------------------------------------------------------- /src/overlay/utils.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode, ReactElement } from "react"; 2 | import { renderToString } from "react-dom/server"; 3 | import OgmaLib, { Point, Size } from "@linkurious/ogma"; 4 | import { Content, PositionGetter, Placement } from "./types"; 5 | 6 | export function getContent( 7 | ogma: OgmaLib, 8 | position: Point, 9 | content?: Content, 10 | children?: ReactNode 11 | ): string { 12 | if (typeof content === "string") return content; 13 | else if (typeof content === "function") 14 | return renderToString(content(ogma, position)); 15 | return renderToString(children as any); 16 | } 17 | 18 | export function getPosition(position: Point | PositionGetter, ogma: OgmaLib) { 19 | if (typeof position === "function") return position(ogma); 20 | return position; 21 | } 22 | 23 | export const getContainerClass = (popupClass: string, placement: Placement) => 24 | `${popupClass} ${popupClass}--${placement}`; 25 | 26 | export function getCloseButton( 27 | closeButton: string | ReactNode | null = "×", 28 | closeButtonClass: string 29 | ) { 30 | if (closeButton) { 31 | const closeButtonElement = 32 | typeof closeButton === "string" 33 | ? closeButton 34 | : renderToString(closeButton as ReactElement); 35 | return `
${closeButtonElement}
`; 36 | } 37 | return ""; 38 | } 39 | 40 | export function getAdjustedPlacement( 41 | coords: Point, 42 | placement: Placement, 43 | dimensions: Size, 44 | ogma: OgmaLib 45 | ): Placement { 46 | const { width: screenWidth, height: screenHeight } = ogma.view.getSize(); 47 | const { x, y } = ogma.view.graphToScreenCoordinates(coords); 48 | let res = placement; 49 | const { width, height } = dimensions; 50 | 51 | if (placement === "left" && x - width < 0) res = "right"; 52 | else if (placement === "right" && x + width > screenWidth) res = "left"; 53 | else if (placement === "bottom" && y + height > screenHeight) res = "top"; 54 | else if (placement === "top" && y - height < 0) res = "bottom"; 55 | 56 | if (res === "right" || res === "left") { 57 | if (y + height / 2 > screenHeight) res = "top"; 58 | else if (y - height / 2 < 0) res = "bottom"; 59 | } else { 60 | if (x + width / 2 > screenWidth) res = "left"; 61 | else if (x - width / 2 < 0) res = "right"; 62 | } 63 | 64 | return res; 65 | } 66 | -------------------------------------------------------------------------------- /src/styles/classStyle.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | StyleClassDefinition, 3 | StyleClass as OgmaStyleClass 4 | } from "@linkurious/ogma"; 5 | import { useOgma } from "../context"; 6 | import { 7 | forwardRef, 8 | Ref, 9 | useEffect, 10 | useImperativeHandle, 11 | useState, 12 | memo 13 | } from "react"; 14 | 15 | interface StyleProps extends StyleClassDefinition { 16 | name: string; 17 | } 18 | 19 | const styleClassComponent = ( 20 | { 21 | name, 22 | edgeAttributes, 23 | edgeDependencies, 24 | edgeOutput, 25 | nodeAttributes, 26 | nodeDependencies, 27 | nodeOutput 28 | }: StyleProps, 29 | ref?: Ref> 30 | ) => { 31 | const ogma = useOgma(); 32 | const [styleClass, setStyleClass] = useState | null>( 33 | null 34 | ); 35 | 36 | useImperativeHandle(ref, () => styleClass as OgmaStyleClass, [ 37 | styleClass 38 | ]); 39 | 40 | useEffect(() => { 41 | const style = { 42 | edgeAttributes, 43 | edgeDependencies, 44 | edgeOutput, 45 | nodeAttributes, 46 | nodeDependencies, 47 | nodeOutput 48 | }; 49 | 50 | async function setup() { 51 | const newStyleClass = ogma.styles.createClass({ name, ...style }); 52 | await ogma.view.afterNextFrame(); 53 | setStyleClass(newStyleClass); 54 | } 55 | setup(); 56 | 57 | return () => { 58 | async function cleanup() { 59 | // Only destroy if we have a reference and it still exists 60 | const currentClass = ogma.styles.getClass(name); 61 | if (currentClass) await currentClass.destroy(); 62 | setStyleClass(null); 63 | } 64 | cleanup(); 65 | }; 66 | }, []); 67 | 68 | useEffect(() => { 69 | if (!styleClass) return; 70 | styleClass.update({ 71 | edgeAttributes, 72 | edgeDependencies, 73 | edgeOutput, 74 | nodeAttributes, 75 | nodeDependencies, 76 | nodeOutput 77 | }); 78 | }, [ 79 | edgeAttributes, 80 | edgeDependencies, 81 | edgeOutput, 82 | nodeAttributes, 83 | nodeDependencies, 84 | nodeOutput 85 | ]); 86 | 87 | return null; 88 | }; 89 | 90 | const arePropsEqual = ( 91 | prev: StyleProps, 92 | next: StyleProps 93 | ) => { 94 | return ( 95 | prev.name === next.name && 96 | prev.edgeAttributes === next.edgeAttributes && 97 | prev.edgeDependencies === next.edgeDependencies && 98 | prev.edgeOutput === next.edgeOutput && 99 | prev.nodeAttributes === next.nodeAttributes && 100 | prev.nodeDependencies === next.nodeDependencies && 101 | prev.nodeOutput === next.nodeOutput 102 | ); 103 | }; 104 | 105 | /** 106 | * This component wraps around Ogma [`StyleClass`](https://doc.linkurio.us/ogma/latest/api.html#Ogma-styles-addClassStyle) API. It allows you to add a class style to the 107 | * Ogma instance to calculate the visual appearance attributes of the nodes and edges. 108 | */ 109 | export const StyleClass = memo(forwardRef(styleClassComponent), arePropsEqual); 110 | -------------------------------------------------------------------------------- /src/styles/edgeStyle.tsx: -------------------------------------------------------------------------------- 1 | import OgmaLib, { 2 | EdgeSelector, 3 | EdgeAttributesValue, 4 | StyleRule 5 | } from "@linkurious/ogma"; 6 | import { 7 | useEffect, 8 | useState, 9 | Ref, 10 | forwardRef, 11 | useImperativeHandle 12 | } from "react"; 13 | import { useOgma } from "../context"; 14 | 15 | interface EdgeRuleProps { 16 | selector?: EdgeSelector; 17 | attributes: EdgeAttributesValue; 18 | } 19 | 20 | const EdgeStyleRuleComponent = ( 21 | { selector, attributes }: EdgeRuleProps, 22 | ref?: Ref> 23 | ) => { 24 | const ogma = useOgma() as OgmaLib; 25 | const [rule, setRule] = useState>(); 26 | 27 | useImperativeHandle(ref, () => rule as StyleRule, [rule]); 28 | 29 | useEffect(() => { 30 | //if (rule) rule.destroy(); 31 | const edgeRule = selector 32 | ? ogma.styles.addEdgeRule(selector, attributes) 33 | : ogma.styles.addEdgeRule(attributes); 34 | setRule(edgeRule); 35 | return () => { 36 | edgeRule.destroy(); 37 | setRule(undefined); 38 | }; 39 | }, [selector, attributes]); 40 | return null; 41 | }; 42 | 43 | /** 44 | * This component wraps around Ogma [`EdgeStyle` API](https://doc.linkurio.us/ogma/latest/api.html#Ogma-styles-addEdgeRule). It allows you to add a node style rule to the 45 | * Ogma instance to calculate the visual appearance attributes of the edges. 46 | */ 47 | export const EdgeStyleRule = forwardRef(EdgeStyleRuleComponent); 48 | -------------------------------------------------------------------------------- /src/styles/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./nodeStyle"; 2 | export * from "./edgeStyle"; 3 | export * from "./classStyle"; -------------------------------------------------------------------------------- /src/styles/nodeStyle.tsx: -------------------------------------------------------------------------------- 1 | import OgmaLib, { 2 | NodeSelector, 3 | NodeAttributesValue, 4 | StyleRule 5 | } from "@linkurious/ogma"; 6 | import { 7 | useEffect, 8 | Ref, 9 | forwardRef, 10 | useState, 11 | useImperativeHandle 12 | } from "react"; 13 | import { useOgma } from "../context"; 14 | 15 | interface NodeRuleProps { 16 | selector?: NodeSelector; 17 | attributes: NodeAttributesValue; 18 | } 19 | 20 | const NodeStyleRuleComponent = ( 21 | { selector, attributes }: NodeRuleProps, 22 | ref?: Ref> 23 | ) => { 24 | const ogma = useOgma() as OgmaLib; 25 | const [rule, setRule] = useState>(); 26 | 27 | useImperativeHandle(ref, () => rule as StyleRule, [rule]); 28 | 29 | useEffect(() => { 30 | const nodeRule = selector 31 | ? ogma.styles.addNodeRule(selector, attributes) 32 | : ogma.styles.addNodeRule(attributes); 33 | setRule(nodeRule); 34 | return () => { 35 | nodeRule.destroy(); 36 | setRule(undefined); 37 | }; 38 | }, [selector, attributes]); 39 | return null; 40 | }; 41 | 42 | /** 43 | * This component wraps around Ogma [`NodeStyle` API](https://doc.linkurio.us/ogma/latest/api.html#Ogma-styles-addNodeRule). It allows you to add a node style rule to the 44 | * Ogma instance to calculate the visual appearance attributes of the nodes. 45 | */ 46 | export const NodeStyleRule = forwardRef(NodeStyleRuleComponent); 47 | -------------------------------------------------------------------------------- /src/transformations/edgeFilter.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState, 4 | Ref, 5 | useImperativeHandle, 6 | forwardRef 7 | } from "react"; 8 | import { 9 | EdgeFilterOptions, 10 | EdgeFilter as EdgeFilterTransformation 11 | } from "@linkurious/ogma"; 12 | import { useOgma } from "../context"; 13 | import { TransformationProps } from "./types"; 14 | import { toggle, useTransformationCallbacks } from "./utils"; 15 | 16 | export interface EdgeFilterProps 17 | extends EdgeFilterOptions, 18 | TransformationProps> {} 19 | 20 | function EdgeFilterComponent( 21 | props: EdgeFilterProps, 22 | ref?: Ref> 23 | ) { 24 | const ogma = useOgma(); 25 | const [transformation, setTransformation] = 26 | useState>(); 27 | 28 | useImperativeHandle(ref, () => transformation!, [transformation]); 29 | 30 | useEffect(() => { 31 | const newTransformation = ogma.transformations.addEdgeFilter({ 32 | ...props, 33 | enabled: !props.disabled 34 | }); 35 | useTransformationCallbacks(props, newTransformation, ogma); 36 | setTransformation(newTransformation); 37 | return () => { 38 | newTransformation.destroy(); 39 | setTransformation(undefined); 40 | }; 41 | }, []); 42 | 43 | useEffect(() => { 44 | if (transformation) { 45 | toggle(transformation, !!props.disabled, props.duration); 46 | } 47 | }, [props.disabled]); 48 | 49 | useEffect(() => { 50 | transformation?.setOptions(props); 51 | }, [props.criteria]); 52 | 53 | return null; 54 | } 55 | 56 | /** 57 | * Edge Filter transformation component. It wraps around Ogma [`EdgeFilter` API](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addEdgeFilter). 58 | */ 59 | export const EdgeFilter = forwardRef(EdgeFilterComponent); 60 | -------------------------------------------------------------------------------- /src/transformations/edgeGrouping.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState, 4 | Ref, 5 | useImperativeHandle, 6 | forwardRef 7 | } from "react"; 8 | import { 9 | EdgeGroupingOptions, 10 | EdgeGrouping as EdgeGroupingTransformation 11 | } from "@linkurious/ogma"; 12 | import { useOgma } from "../context"; 13 | import { TransformationProps } from "./types"; 14 | import { toggle, useTransformationCallbacks } from "./utils"; 15 | 16 | export interface EdgeGroupingProps 17 | extends EdgeGroupingOptions, 18 | TransformationProps> {} 19 | 20 | function EdgeGroupingComponent( 21 | props: EdgeGroupingProps, 22 | ref?: Ref> 23 | ) { 24 | const ogma = useOgma(); 25 | const [transformation, setTransformation] = 26 | useState>(); 27 | 28 | useImperativeHandle(ref, () => transformation!, [transformation]); 29 | 30 | useEffect(() => { 31 | const newTransformation = ogma.transformations.addEdgeGrouping({ 32 | ...props, 33 | enabled: !props.disabled 34 | }); 35 | // @ts-expect-error transformation is generic 36 | useTransformationCallbacks(props, newTransformation, ogma); 37 | setTransformation(newTransformation); 38 | return () => { 39 | newTransformation.destroy(); 40 | setTransformation(undefined); 41 | }; 42 | }, []); 43 | 44 | useEffect(() => { 45 | if (transformation) { 46 | toggle(transformation, !!props.disabled, props.duration); 47 | } 48 | }, [props.disabled]); 49 | 50 | useEffect(() => { 51 | transformation?.setOptions(props); 52 | }, [ 53 | props.selector, 54 | props.generator, 55 | props.groupIdFunction, 56 | props.separateEdgesByDirection 57 | ]); 58 | 59 | return null; 60 | } 61 | 62 | /** 63 | * Edge grouping transformation component. It wraps around Ogma [`EdgeGrouping` API](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addEdgeGrouping). 64 | */ 65 | export const EdgeGrouping = forwardRef(EdgeGroupingComponent); 66 | -------------------------------------------------------------------------------- /src/transformations/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./nodeGrouping"; 2 | export * from "./edgeGrouping"; 3 | export * from "./nodeCollapsing"; 4 | export * from "./neighborMerging"; 5 | export * from "./neighborGeneration"; 6 | export * from "./edgeFilter"; 7 | export * from "./nodeFilter"; 8 | -------------------------------------------------------------------------------- /src/transformations/neighborGeneration.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState, 4 | Ref, 5 | useImperativeHandle, 6 | forwardRef 7 | } from "react"; 8 | import { 9 | NeighborGenerationOptions, 10 | NeighborGeneration as NeighborGenerationTransformation 11 | } from "@linkurious/ogma"; 12 | import { useOgma } from "../context"; 13 | import { TransformationProps } from "./types"; 14 | import { toggle, useTransformationCallbacks } from "./utils"; 15 | 16 | export interface NeighborGenerationProps 17 | extends NeighborGenerationOptions, 18 | TransformationProps> {} 19 | 20 | function NeighborGenerationComponent( 21 | props: NeighborGenerationProps, 22 | ref: Ref> 23 | ) { 24 | const ogma = useOgma(); 25 | const [transformation, setTransformation] = 26 | useState>(); 27 | 28 | useImperativeHandle(ref, () => transformation!, [transformation]); 29 | 30 | useEffect(() => { 31 | const newTransformation = ogma.transformations.addNeighborGeneration({ 32 | ...props, 33 | enabled: !props.disabled 34 | }); 35 | // @ts-expect-error transformation is generic 36 | useTransformationCallbacks(props, newTransformation, ogma); 37 | setTransformation(newTransformation); 38 | return () => { 39 | newTransformation.destroy(); 40 | setTransformation(undefined); 41 | }; 42 | }, []); 43 | 44 | useEffect(() => { 45 | if (transformation) { 46 | toggle(transformation, !!props.disabled, props.duration); 47 | } 48 | }, [props.disabled]); 49 | 50 | useEffect(() => { 51 | transformation?.setOptions(props); 52 | }, [ 53 | props.edgeGenerator, 54 | props.nodeGenerator, 55 | props.neighborIdFunction, 56 | props.selector 57 | ]); 58 | 59 | return null; 60 | } 61 | 62 | export const NeighborGeneration = forwardRef(NeighborGenerationComponent); 63 | -------------------------------------------------------------------------------- /src/transformations/neighborMerging.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState, 4 | Ref, 5 | useImperativeHandle, 6 | forwardRef 7 | } from "react"; 8 | import { 9 | NeighborMergingOptions, 10 | NeighborMerging as NeighborMergingTransformation 11 | } from "@linkurious/ogma"; 12 | import { useOgma } from "../context"; 13 | import { TransformationProps } from "./types"; 14 | import { toggle, useTransformationCallbacks } from "./utils"; 15 | 16 | export interface NeighborMergingProps 17 | extends NeighborMergingOptions, 18 | TransformationProps> {} 19 | 20 | function NeighborMergingComponent( 21 | props: NeighborMergingProps, 22 | ref: Ref> 23 | ) { 24 | const ogma = useOgma(); 25 | const [transformation, setTransformation] = 26 | useState>(); 27 | 28 | useImperativeHandle(ref, () => transformation!, [transformation]); 29 | 30 | useEffect(() => { 31 | const newTransformation = ogma.transformations.addNeighborMerging({ 32 | ...props, 33 | enabled: !props.disabled 34 | }); 35 | // @ts-expect-error transformation is generic 36 | useTransformationCallbacks(props, newTransformation, ogma); 37 | setTransformation(newTransformation); 38 | return () => { 39 | newTransformation.destroy(); 40 | setTransformation(undefined); 41 | }; 42 | }, []); 43 | 44 | useEffect(() => { 45 | if (transformation) { 46 | toggle(transformation, !!props.disabled, props.duration); 47 | } 48 | }, [props.disabled]); 49 | 50 | useEffect(() => { 51 | transformation?.setOptions(props); 52 | }, [props.dataFunction, props.selector]); 53 | 54 | return null; 55 | } 56 | 57 | export const NeighborMerging = forwardRef(NeighborMergingComponent); 58 | -------------------------------------------------------------------------------- /src/transformations/nodeCollapsing.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState, 4 | Ref, 5 | useImperativeHandle, 6 | forwardRef 7 | } from "react"; 8 | import { 9 | NodeCollapsingOptions, 10 | NodeCollapsing as NodeCollapsingTransformation 11 | } from "@linkurious/ogma"; 12 | import { useOgma } from "../context"; 13 | import { TransformationProps } from "./types"; 14 | import { toggle, useTransformationCallbacks } from "./utils"; 15 | 16 | export interface NodeCollapsingProps 17 | extends NodeCollapsingOptions, 18 | TransformationProps> {} 19 | 20 | export function NodeCollapsingComponent( 21 | props: NodeCollapsingProps, 22 | ref: Ref> 23 | ) { 24 | const ogma = useOgma(); 25 | const [transformation, setTransformation] = 26 | useState>(); 27 | 28 | useImperativeHandle(ref, () => transformation!, [transformation]); 29 | 30 | useEffect(() => { 31 | const newTransformation = ogma.transformations.addNodeCollapsing({ 32 | ...props, 33 | enabled: !props.disabled 34 | }); 35 | useTransformationCallbacks(props, newTransformation, ogma); 36 | setTransformation(newTransformation); 37 | 38 | return () => { 39 | newTransformation.destroy(); 40 | setTransformation(undefined); 41 | }; 42 | }, []); 43 | 44 | useEffect(() => { 45 | if (transformation) { 46 | toggle(transformation, !!props.disabled, props.duration); 47 | } 48 | }, [props.disabled]); 49 | 50 | useEffect(() => { 51 | transformation?.setOptions(props); 52 | }, [props.edgeGenerator, props.selector]); 53 | 54 | return null; 55 | } 56 | 57 | export const NodeCollapsing = forwardRef(NodeCollapsingComponent); 58 | -------------------------------------------------------------------------------- /src/transformations/nodeFilter.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState, 4 | Ref, 5 | useImperativeHandle, 6 | forwardRef 7 | } from "react"; 8 | import { 9 | NodeFilterOptions, 10 | NodeFilter as NodeFilterTransformation 11 | } from "@linkurious/ogma"; 12 | import { useOgma } from "../context"; 13 | import { TransformationProps } from "./types"; 14 | import { toggle, useTransformationCallbacks } from "./utils"; 15 | 16 | export interface NodeFilterProps 17 | extends NodeFilterOptions, 18 | TransformationProps> {} 19 | 20 | function NodeFilterComponent( 21 | props: NodeFilterProps, 22 | ref?: Ref> 23 | ) { 24 | const ogma = useOgma(); 25 | const [transformation, setTransformation] = 26 | useState>(); 27 | 28 | useImperativeHandle(ref, () => transformation!, [transformation]); 29 | 30 | useEffect(() => { 31 | const newTransformation = ogma.transformations.addNodeFilter({ 32 | ...props, 33 | enabled: !props.disabled 34 | }); 35 | // @ts-expect-error transformation is generic 36 | useTransformationCallbacks(props, newTransformation, ogma); 37 | setTransformation(newTransformation); 38 | return () => { 39 | newTransformation.destroy(); 40 | setTransformation(undefined); 41 | }; 42 | }, []); 43 | 44 | useEffect(() => { 45 | if (transformation) { 46 | toggle(transformation, !!props.disabled, props.duration); 47 | } 48 | }, [props.disabled]); 49 | 50 | useEffect(() => { 51 | transformation?.setOptions(props); 52 | }, [props.criteria]); 53 | 54 | return null; 55 | } 56 | 57 | /** 58 | * Edge Filter transformation component. It wraps around Ogma [`NodeFilter` API](https://doc.linkurio.us/ogma/latest/api.html#Ogma-transformations-addNodeFilter). 59 | */ 60 | export const NodeFilter = forwardRef(NodeFilterComponent); 61 | -------------------------------------------------------------------------------- /src/transformations/nodeGrouping.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState, 4 | Ref, 5 | useImperativeHandle, 6 | forwardRef 7 | } from "react"; 8 | import { 9 | NodeGroupingOptions, 10 | NodeGrouping as NodeGroupingTransformation 11 | } from "@linkurious/ogma"; 12 | import { useOgma } from "../context"; 13 | import { toggle, useTransformationCallbacks } from "./utils"; 14 | import { TransformationProps } from "./types"; 15 | 16 | export interface NodeGroupingProps 17 | extends NodeGroupingOptions, 18 | TransformationProps> {} 19 | 20 | function NodeGroupingComponent( 21 | props: NodeGroupingProps, 22 | ref?: Ref> 23 | ) { 24 | const ogma = useOgma(); 25 | const [transformation, setTransformation] = 26 | useState>(); 27 | 28 | useImperativeHandle(ref, () => transformation!, [transformation]); 29 | useEffect(() => { 30 | const newTransformation = ogma.transformations.addNodeGrouping({ 31 | ...props, 32 | enabled: !props.disabled 33 | }); 34 | useTransformationCallbacks(props, newTransformation, ogma); 35 | setTransformation(newTransformation); 36 | return () => { 37 | newTransformation.destroy(); 38 | setTransformation(undefined); 39 | }; 40 | }, []); 41 | 42 | useEffect(() => { 43 | if (transformation) { 44 | toggle(transformation, !!props.disabled, props.duration); 45 | } 46 | }, [props.disabled]); 47 | 48 | useEffect(() => { 49 | transformation?.setOptions(props); 50 | }, [ 51 | props.groupIdFunction, 52 | props.groupSelfLoopEdges, 53 | props.edgeGenerator, 54 | props.nodeGenerator, 55 | props.groupEdges, 56 | props.padding, 57 | props.selector, 58 | props.showContents, 59 | props.separateEdgesByDirection 60 | ]); 61 | 62 | return null; 63 | } 64 | 65 | export const NodeGrouping = forwardRef(NodeGroupingComponent); 66 | -------------------------------------------------------------------------------- /src/transformations/types.ts: -------------------------------------------------------------------------------- 1 | import { Transformation } from "@linkurious/ogma"; 2 | 3 | export interface TransformationContext {} 4 | /** TODO: expose that in Ogma */ 5 | export interface TransformationOptions { 6 | duration?: number; 7 | enabled?: boolean; 8 | } 9 | 10 | export interface TransformationProps< 11 | ND, 12 | ED, 13 | C extends TransformationContext = TransformationContext 14 | > { 15 | disabled?: boolean; 16 | onEnabled?: (transformation: Transformation) => void; 17 | onDisabled?: (transformation: Transformation) => void; 18 | onDestroyed?: (transformation: Transformation) => void; 19 | onUpdated?: (transformation: Transformation) => void; 20 | onSetIndex?: ( 21 | transformation: Transformation, 22 | index: number 23 | ) => void; 24 | } 25 | -------------------------------------------------------------------------------- /src/transformations/utils.ts: -------------------------------------------------------------------------------- 1 | import Ogma, { Transformation } from "@linkurious/ogma"; 2 | import { TransformationProps } from "./types"; 3 | 4 | export function toggle( 5 | transformation: Transformation, 6 | disabled: boolean, 7 | duration?: number 8 | ) { 9 | if (disabled === transformation.isEnabled()) { 10 | if (disabled) transformation.disable(duration as number); 11 | else transformation.enable(duration as number); 12 | } 13 | } 14 | 15 | export function useTransformationCallbacks( 16 | props: TransformationProps, 17 | transformation: Transformation, 18 | ogma: Ogma 19 | ) { 20 | const enabledListener = ({ target }: { target: Transformation }) => { 21 | if (target !== transformation) return; 22 | props.onEnabled && props.onEnabled(transformation); 23 | }; 24 | const disabledListener = ({ target }: { target: Transformation }) => { 25 | if (target !== transformation) return; 26 | props.onDisabled && props.onDisabled(transformation); 27 | }; 28 | const updatedListener = ({ target }: { target: Transformation }) => { 29 | if (target !== transformation) return; 30 | props.onUpdated && props.onUpdated(transformation); 31 | }; 32 | const setIndexListener = ({ 33 | target, 34 | index 35 | }: { 36 | target: Transformation; 37 | index: number; 38 | }) => { 39 | if (target !== transformation) return; 40 | props.onSetIndex && props.onSetIndex(transformation, index); 41 | }; 42 | const destroyedListener = ({ 43 | target 44 | }: { 45 | target: Transformation; 46 | }) => { 47 | if (target !== transformation) return; 48 | props.onDestroyed && props.onDestroyed(transformation); 49 | ogma.events 50 | .off(enabledListener) 51 | .off(disabledListener) 52 | .off(updatedListener) 53 | .off(setIndexListener) 54 | .off(destroyedListener); 55 | }; 56 | ogma.events 57 | .on("transformationEnabled", enabledListener) 58 | .on("transformationDisabled", disabledListener) 59 | .on("transformationDestroyed", destroyedListener) 60 | .on("transformationSetIndex", setIndexListener) 61 | .on("transformationRefresh", updatedListener); 62 | const cleanup = () => { 63 | ogma.events 64 | .off(enabledListener) 65 | .off(disabledListener) 66 | .off(updatedListener) 67 | .off(setIndexListener) 68 | .off(destroyedListener); 69 | }; 70 | return cleanup; 71 | } 72 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EdgeAttributes, 3 | EventTypes, 4 | HoverEdgeOptions, 5 | HoverNodeOptions, 6 | NodeAttributes 7 | } from "@linkurious/ogma"; 8 | 9 | // temporary while the type import gets added 10 | export interface Theme { 11 | nodeAttributes?: NodeAttributes; 12 | edgeAttributes?: EdgeAttributes; 13 | selectedNodeAttributes?: NodeAttributes; 14 | selectedEdgeAttributes?: EdgeAttributes; 15 | hoveredNodeAttributes?: HoverNodeOptions; 16 | hoveredEdgeAttributes?: HoverEdgeOptions; 17 | } 18 | 19 | export type EventNames = keyof EventTypes; 20 | 21 | export type EventHandlers = { 22 | [K in EventNames]?: (event: EventTypes[K]) => void; 23 | }; 24 | 25 | // Generate component props from EventTypes 26 | export type EventHandlerProps = { 27 | [K in keyof T as `on${Capitalize}`]?: (event: T[K]) => void; 28 | }; 29 | 30 | // Type-safe function to iterate through event handlers 31 | export function forEachEventHandler( 32 | handlers: EventHandlers, 33 | callback: >( 34 | eventName: K, 35 | handler: (event: EventTypes[K]) => void 36 | ) => void 37 | ) { 38 | // Type-safe iteration 39 | (Object.keys(handlers) as EventNames[]).forEach((eventName) => { 40 | const handler = handlers[eventName]; 41 | // @ts-expect-error type union 42 | if (handler) callback(eventName, handler); 43 | }); 44 | } 45 | 46 | // Helper to convert onEventName to eventname 47 | export function getEventNameFromProp( 48 | propName: string 49 | ): keyof EventTypes | null { 50 | if (propName.startsWith("on") && propName.length > 2) { 51 | // remove 'on' and convert first letter to lowercase 52 | const eventName = propName[2].toLowerCase() + propName.substring(3); 53 | return eventName as EventNames; 54 | } 55 | return null; 56 | } 57 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function noop() {} 2 | -------------------------------------------------------------------------------- /src/uuid.ts: -------------------------------------------------------------------------------- 1 | export function uuidv4() { 2 | // Public Domain/MIT 3 | var d = new Date().getTime(); //Timestamp 4 | var d2 = 5 | (typeof performance !== "undefined" && 6 | performance.now && 7 | performance.now() * 1000) || 8 | 0; //Time in microseconds since page-load or 0 if unsupported 9 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { 10 | var r = Math.random() * 16; //random number between 0 and 16 11 | if (d > 0) { 12 | //Use timestamp until depleted 13 | r = (d + r) % 16 | 0; 14 | d = Math.floor(d / 16); 15 | } else { 16 | //Use microseconds since page-load if supported 17 | r = (d2 + r) % 16 | 0; 18 | d2 = Math.floor(d2 / 16); 19 | } 20 | return (c === "x" ? r : (r & 0x3) | 0x8).toString(16); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /test/classes.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, act } from "@testing-library/react"; 2 | import { describe, it, beforeEach, vi, expect } from "vitest"; 3 | import { StyleClass } from "../src/styles/classStyle"; 4 | import { Ogma } from "../src"; 5 | import graph from "./fixtures/simple_graph.json"; 6 | 7 | // Mock Ogma and its API 8 | const mockDestroy = vi.fn(); 9 | const mockUpdate = vi.fn(); 10 | const mockCreateClass = vi.fn(() => ({ 11 | update: mockUpdate, 12 | destroy: mockDestroy 13 | })); 14 | const mockGetClass = vi.fn(() => ({ 15 | destroy: mockDestroy 16 | })); 17 | const mockOgma = { 18 | styles: { 19 | createClass: mockCreateClass, 20 | getClass: mockGetClass 21 | }, 22 | view: { 23 | afterNextFrame: () => Promise.resolve() 24 | } 25 | }; 26 | 27 | vi.mock("../src/context", async (importOriginal) => { 28 | const actual = (await importOriginal()) as {}; 29 | return { 30 | ...actual, 31 | useOgma: () => mockOgma 32 | }; 33 | }); 34 | 35 | describe("StyleClass", () => { 36 | beforeEach(() => { 37 | vi.clearAllMocks(); 38 | }); 39 | 40 | it("mounts and creates a style class", async () => { 41 | await act(async () => { 42 | render( 43 | 44 | 51 | 52 | ); 53 | }); 54 | expect(mockCreateClass).toHaveBeenCalledWith( 55 | expect.objectContaining({ name: "test" }) 56 | ); 57 | }); 58 | 59 | it("updates the style class when props change", async () => { 60 | const { rerender } = render( 61 | 62 | 69 | 70 | ); 71 | await act(async () => { 72 | rerender( 73 | 74 | 82 | 83 | ); 84 | }); 85 | expect(mockUpdate).toHaveBeenCalledWith( 86 | expect.objectContaining({ edgeAttributes: { a: 2 } }) 87 | ); 88 | }); 89 | 90 | it("cleans up on unmount", async () => { 91 | const { unmount } = render( 92 | 93 | 100 | 101 | ); 102 | await act(async () => { 103 | unmount(); 104 | }); 105 | expect(mockDestroy).toHaveBeenCalled(); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /test/fixtures/simple_graph.json: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": [ 3 | { "id": 0, "attributes": { "color": "blue", "x": 0, "y": 0 } }, 4 | { "id": 1, "attributes": { "color": "cyan", "x": 25, "y": 0 } }, 5 | { "id": 2, "attributes": { "color": "green", "x": 25, "y": 0 } } 6 | ], 7 | "edges": [ 8 | { "id": 0, "source": 0, "target": 1 }, 9 | { "id": 1, "source": 0, "target": 2 } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /test/fixtures/simple_graph_curved.json: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": [ 3 | { 4 | "id": 0, 5 | "attributes": { 6 | "color": "blue", 7 | "x": 0, 8 | "y": 0 9 | } 10 | }, 11 | { 12 | "id": 1, 13 | "attributes": { 14 | "color": "cyan", 15 | "x": 25, 16 | "y": 0 17 | } 18 | }, 19 | { 20 | "id": 2, 21 | "attributes": { 22 | "color": "green", 23 | "x": 25, 24 | "y": 0 25 | } 26 | } 27 | ], 28 | "edges": [ 29 | { 30 | "id": 0, 31 | "source": 0, 32 | "target": 1 33 | }, 34 | { 35 | "id": 1, 36 | "source": 0, 37 | "target": 2 38 | }, 39 | { 40 | "id": 2, 41 | "source": 0, 42 | "target": 1 43 | }, 44 | { 45 | "id": 3, 46 | "source": 0, 47 | "target": 2 48 | } 49 | ] 50 | } -------------------------------------------------------------------------------- /test/ogma.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, waitFor } from "./utils"; 3 | 4 | import OgmaLib, { RawGraph } from "@linkurious/ogma"; 5 | import { Ogma, useOgma } from "../src"; 6 | import { Theme } from "../src/types"; 7 | import { afternoonNap, morningBreeze } from '@linkurious/ogma-styles'; 8 | import { vi, describe, it, expect, beforeEach } from "vitest"; 9 | 10 | const graph: RawGraph = { 11 | nodes: [ 12 | { id: 0, attributes: { color: "red", x: 0, y: 0 } }, 13 | { id: 1, attributes: { color: "green", x: 25, y: 0 } }, 14 | { id: 2, attributes: { color: "green", x: 25, y: 0 } } 15 | ], 16 | edges: [ 17 | { source: 0, target: 1 }, 18 | { source: 0, target: 2 } 19 | ] 20 | }; 21 | 22 | describe("Ogma", () => { 23 | let div: HTMLDivElement; 24 | beforeEach(() => (div = document.createElement("div"))); 25 | 26 | it("Ogma container renders without crashing", () => { 27 | render(, div); 28 | }); 29 | 30 | it("Supports ref interface", () => { 31 | const ref = React.createRef(); 32 | render(, div); 33 | expect(ref.current).toBeDefined(); 34 | expect(ref.current).toBeInstanceOf(OgmaLib); 35 | }); 36 | 37 | it("Ogma container renders with onReady callback", () => { 38 | return new Promise((resolve) => { 39 | render( resolve(ogma)} />, div); 40 | }).then((ogma) => { 41 | expect(ogma).toBeInstanceOf(OgmaLib); 42 | }); 43 | }); 44 | 45 | it("Ogma container renders and takes options", () => { 46 | const backgroundColor = "red"; 47 | const minimumWidth = 500; 48 | return new Promise((resolve) => { 49 | const onReady = (ogma: OgmaLib) => { 50 | const options = ogma.getOptions(); 51 | expect(options.backgroundColor).toBe(backgroundColor); 52 | expect(options.minimumWidth).toBe(minimumWidth); 53 | return resolve(null); 54 | }; 55 | render( 56 | , 61 | div 62 | ); 63 | }); 64 | }); 65 | 66 | it("Ogma container passes the ogma instance to children", () => { 67 | return new Promise((resolve) => { 68 | const Component = () => { 69 | const ogma = useOgma(); 70 | expect(ogma).toBeInstanceOf(OgmaLib); 71 | resolve(null); 72 | return <>; 73 | }; 74 | render( 75 | 76 | 77 | , 78 | div 79 | ); 80 | }); 81 | }); 82 | 83 | it("should handle onNodesAdded prop changes correctly", async () => { 84 | const mockData = { 85 | nodes: [ 86 | { id: 0, attributes: { color: "red", x: 0, y: 0 } }, 87 | { id: 1, attributes: { color: "green", x: 25, y: 0 } }, 88 | ] 89 | } as RawGraph; 90 | 91 | const mockOnNodesAdded = vi.fn(() => console.log("onNodesAdded")); 92 | let ready = false; 93 | 94 | const ref = React.createRef(); 95 | const onReady = () => { 96 | ready = true; 97 | }; 98 | 99 | const { rerender } = render( 100 | , 101 | div 102 | ); 103 | 104 | // Wait for Ogma to initialize 105 | await waitFor(() => ready); 106 | 107 | // add a node 108 | ref.current?.addNode({ id: 2, attributes: { color: "blue", x: 0, y: 25 } }); 109 | 110 | // Check if the handler was called 111 | expect(mockOnNodesAdded).toHaveBeenCalledTimes(1); 112 | 113 | // Rerender without the handler 114 | rerender(); 115 | 116 | ref.current?.addNode({ id: 3, attributes: { color: "yellow", x: 25, y: 25 } }); 117 | 118 | // add another node - handler should not be called 119 | expect(mockOnNodesAdded).toHaveBeenCalledTimes(1); 120 | 121 | rerender(); 122 | 123 | ref.current?.addNode({ id: 4, attributes: { color: "purple", x: 50, y: 50 } }); 124 | 125 | // Check if the handler was called again 126 | expect(mockOnNodesAdded).toHaveBeenCalledTimes(2); 127 | }); 128 | 129 | it("should set the theme of the graph correctly with the prop changes", async () => { 130 | const mockData = { 131 | nodes: [ 132 | { id: 0, attributes: { x: 0, y: 0 } }, 133 | ] 134 | } as RawGraph; 135 | 136 | const theme: Theme = afternoonNap as Theme; 137 | let ready = false; 138 | const ref = React.createRef(); 139 | const onReady = () => { 140 | ready = true; 141 | }; 142 | 143 | const { rerender } = render( 144 | , 145 | div 146 | ); 147 | 148 | await waitFor(() => ready); 149 | 150 | // node color should be the theme's node color 151 | const nodeColor = ref.current?.getNodes().get(0).getAttribute("color"); 152 | expect(nodeColor).toMatch("#BD8A61"); 153 | 154 | const theme2: Theme = morningBreeze as Theme; 155 | rerender() 156 | 157 | // node color should be the new theme's node color after rerendering 158 | const nodeColor2 = ref.current?.getNodes().get(0).getAttribute("color"); 159 | expect(nodeColor2).toMatch("#43a2ca"); 160 | 161 | }) 162 | }); 163 | -------------------------------------------------------------------------------- /test/popup.test.tsx: -------------------------------------------------------------------------------- 1 | import { createRef } from "react"; 2 | import { render } from "./utils"; 3 | 4 | import { Ogma, Popup } from "../src"; 5 | import { Overlay, Point } from "@linkurious/ogma"; 6 | import graph from "./fixtures/simple_graph.json"; 7 | 8 | describe("Popup", () => { 9 | let div: HTMLDivElement; 10 | beforeEach(() => (div = document.createElement("div"))); 11 | 12 | it("should support ref", () => { 13 | const ref = createRef(); 14 | render( 15 | 16 | { 19 | const bbox = ogma.view.getGraphBoundingBox(); 20 | return { x: bbox.cx, y: bbox.cy }; 21 | }} 22 | placement="right" 23 | > 24 | content 25 | 26 | , 27 | div 28 | ); 29 | 30 | expect(ref.current).toBeDefined(); 31 | expect(ref.current!.hide).toBeDefined(); 32 | }); 33 | it("should add popup", () => { 34 | const text = "Custom popup text"; 35 | const ref = createRef(); 36 | 37 | render( 38 | 39 | 40 |
{text}
41 |
42 |
, 43 | div 44 | ); 45 | expect( 46 | ref.current?.element.querySelector(".ogma-popup--body") 47 | ).toBeInstanceOf(HTMLElement); 48 | expect( 49 | ref.current?.element.querySelector(".ogma-popup--close") 50 | ).toBeDefined(); 51 | expect( 52 | ref.current?.element.querySelector(".custom-child-div") 53 | ).toBeInstanceOf(HTMLElement); 54 | expect( 55 | ref.current?.element.querySelector(".custom-child-div")!.textContent 56 | ).toBe(text); 57 | }); 58 | 59 | it("should support positioning", () => { 60 | let pos: Point; 61 | const ref = createRef(); 62 | render( 63 | 64 | { 67 | const bbox = ogma.view.getGraphBoundingBox(); 68 | pos = { x: bbox.cx, y: bbox.cy }; 69 | return pos; 70 | }} 71 | > 72 | Popup 73 | 74 | , 75 | div 76 | ); 77 | expect((ref.current?.element as HTMLDivElement).style.transform).toContain( 78 | `translate(150px, 150px) rotate(0rad) translate(0px, 0px)` 79 | ); 80 | }); 81 | 82 | it("should support custom className", () => { 83 | const ref = createRef(); 84 | render( 85 | 86 | { 90 | const bbox = ogma.view.getGraphBoundingBox(); 91 | return { x: bbox.cx, y: bbox.cy }; 92 | }} 93 | > 94 | Popup 95 | 96 | , 97 | div 98 | ); 99 | expect(ref.current?.element.classList.contains("custom-class")).toBe(true); 100 | }); 101 | 102 | it("should support custom close button", () => { 103 | const ref = createRef(); 104 | render( 105 | 106 | { 109 | const bbox = ogma.view.getGraphBoundingBox(); 110 | return { x: bbox.cx, y: bbox.cy }; 111 | }} 112 | closeButton={X} 113 | > 114 | content 115 | 116 | , 117 | div 118 | ); 119 | expect( 120 | ref.current?.element.querySelector(".custom-close-button") 121 | ).toBeInstanceOf(HTMLSpanElement); 122 | }); 123 | 124 | it("should support custom bottom placement", () => { 125 | const ref = createRef(); 126 | render( 127 | 128 | { 131 | const bbox = ogma.view.getGraphBoundingBox(); 132 | return { x: bbox.cx, y: bbox.cy }; 133 | }} 134 | placement="bottom" 135 | > 136 | content 137 | 138 | , 139 | div 140 | ); 141 | expect( 142 | (ref.current?.element as HTMLDivElement).classList.contains( 143 | "ogma-popup--bottom" 144 | ) 145 | ).toBe(true); 146 | }); 147 | 148 | it("should support custom left placement", () => { 149 | const ref = createRef(); 150 | render( 151 | 152 | { 155 | const bbox = ogma.view.getGraphBoundingBox(); 156 | return { x: bbox.cx, y: bbox.cy }; 157 | }} 158 | placement="left" 159 | > 160 | content 161 | 162 | , 163 | div 164 | ); 165 | expect( 166 | (ref.current?.element as HTMLDivElement).classList.contains( 167 | "ogma-popup--left" 168 | ) 169 | ).toBe(true); 170 | }); 171 | 172 | it("should support custom right placement", () => { 173 | const ref = createRef(); 174 | render( 175 | 176 | { 179 | const bbox = ogma.view.getGraphBoundingBox(); 180 | return { x: bbox.cx, y: bbox.cy }; 181 | }} 182 | placement="right" 183 | > 184 | content 185 | 186 | , 187 | div 188 | ); 189 | expect( 190 | (ref.current?.element as HTMLDivElement).classList.contains( 191 | "ogma-popup--right" 192 | ) 193 | ).toBe(true); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | 3 | // @ts-ignore 4 | global["IS_REACT_ACT_ENVIRONMENT"] = true; 5 | -------------------------------------------------------------------------------- /test/styles.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { act, createRef } from "react"; 2 | import { Root, createRoot } from "react-dom/client"; 3 | import { userEvent, waitFor } from "./utils"; 4 | import OgmaLib from "@linkurious/ogma"; 5 | import { Ogma, NodeStyleRule, EdgeStyleRule } from "../src"; 6 | import graph from "./fixtures/simple_graph.json"; 7 | 8 | describe("styles", async () => { 9 | let div: Root; 10 | let element: HTMLDivElement; 11 | 12 | beforeEach(() => { 13 | element = document.createElement("div"); 14 | div = createRoot(element); 15 | }); 16 | 17 | it("Node style component renders without crashing", () => { 18 | act(() => 19 | div.render( 20 | 21 | 22 | 23 | ) 24 | ); 25 | }); 26 | 27 | it("Passes node attributes", async () => { 28 | const ref = createRef(); 29 | act(() => 30 | div.render( 31 | 32 | 33 | 34 | ) 35 | ); 36 | await waitFor(() => expect(ref.current).toBeTruthy()); 37 | const ogma = ref.current!; 38 | await ogma.view.afterNextFrame(); 39 | expect(ogma.getNodes().getAttribute("color")).toStrictEqual([ 40 | "red", 41 | "red", 42 | "red", 43 | ]); 44 | }); 45 | 46 | it("Uses selector for NodeStyle", async () => { 47 | const ref = createRef(); 48 | act(() => 49 | div.render( 50 | 51 | Number(node.getId()) < 2} 54 | /> 55 | 56 | ) 57 | ); 58 | await waitFor(() => expect(ref.current).toBeTruthy()); 59 | const ogma = ref.current!; 60 | await ogma.view.afterNextFrame(); 61 | expect(ogma.getNodes().getAttribute("color")).toStrictEqual([ 62 | "red", 63 | "red", 64 | "green", 65 | ]); 66 | }); 67 | 68 | it("NodeStyle cleans up after being removed", async () => { 69 | const ref = createRef(); 70 | const Test = () => { 71 | const [style, setStyle] = React.useState(true); 72 | return ( 73 | 74 | 75 | {style && } 76 | 77 | ); 78 | }; 79 | act(() => div.render()); 80 | await waitFor(() => expect(ref.current).toBeTruthy()); 81 | const button = element.querySelector("button") as HTMLButtonElement; 82 | await act(() => userEvent.click(button)); 83 | expect(ref.current!.styles.getNodeRules().length).toBe(0); 84 | }); 85 | 86 | it("Edge style component renders without crashing", () => { 87 | act(() => 88 | div.render( 89 | 90 | 91 | 92 | ) 93 | ); 94 | }); 95 | 96 | it("Passes edge attributes", async () => { 97 | const ref = createRef(); 98 | act(() => 99 | div.render( 100 | 101 | 102 | 103 | ) 104 | ); 105 | await waitFor(() => expect(ref.current).toBeTruthy()); 106 | await ref.current!.view.afterNextFrame(); 107 | 108 | expect(ref.current!.getEdges().getAttribute("color")).toStrictEqual([ 109 | "red", 110 | "red", 111 | ]); 112 | }); 113 | 114 | it("Uses selector for EdgeStyle", async () => { 115 | const ref = createRef(); 116 | act(() => 117 | div.render( 118 | 119 | Number(edge.getId()) > 0} 122 | /> 123 | 124 | ) 125 | ); 126 | await waitFor(() => expect(ref.current).toBeTruthy()); 127 | await ref.current!.view.afterNextFrame(); 128 | expect(ref.current!.getEdges().getAttribute("color")).toStrictEqual([ 129 | "grey", 130 | "green", 131 | ]); 132 | }); 133 | 134 | it("EdgeStyle cleans up after being removed", async () => { 135 | const ref = createRef(); 136 | const Test = () => { 137 | const [style, setStyle] = React.useState(true); 138 | return ( 139 | 140 | 141 | {style && } 142 | 143 | ); 144 | }; 145 | act(() => div.render()); 146 | await waitFor(() => expect(ref.current).toBeTruthy()); 147 | const button = element.querySelector("button") as HTMLButtonElement; 148 | await act(() => userEvent.click(button)); 149 | expect(ref.current!.styles.getEdgeRules().length).toBe(0); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /test/transformations/edgeFilter.test.tsx: -------------------------------------------------------------------------------- 1 | import { EdgeFilterTest, ref } from "./test-components"; 2 | import { render, userEvent, screen } from "../utils"; 3 | import { act } from "react"; 4 | import OgmaLib from "@linkurious/ogma"; 5 | describe("Edge filter", () => { 6 | let div: HTMLDivElement; 7 | beforeEach(() => (div = document.createElement("div"))); 8 | 9 | it("Can be disabled by default and then enabled", async () => { 10 | render(, div); 11 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 12 | expect(ref.current?.getEdges().getId()).toEqual([0, 1]); 13 | await act(() => userEvent.click(screen.getByText("toggle"))); 14 | await ref.current?.transformations.afterNextUpdate(); 15 | expect(ref.current?.getEdges().getId()).toEqual([0]); 16 | }); 17 | 18 | it("Can be disabled", async () => { 19 | render(, div); 20 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 21 | expect(ref.current?.getEdges().getId()).toEqual([0]); 22 | await act(() => userEvent.click(screen.getByText("toggle"))); 23 | await ref.current?.transformations.afterNextUpdate(); 24 | expect(ref.current?.getEdges().getId()).toEqual([0, 1]); 25 | }); 26 | 27 | it("Updates criteria", async () => { 28 | render(, div); 29 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 30 | expect(ref.current?.getEdges().getId()).toEqual([0]); 31 | await act(() => userEvent.click(screen.getByText("setCriteria"))); 32 | await ref.current?.transformations.afterNextUpdate(); 33 | expect(ref.current?.getEdges().getId()).toEqual([1]); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/transformations/edgeGrouping.test.tsx: -------------------------------------------------------------------------------- 1 | import { EdgeGroupingTest, ref } from "./test-components"; 2 | import { render, userEvent, screen } from "../utils"; 3 | import { act } from "react"; 4 | import OgmaLib from "@linkurious/ogma"; 5 | describe("Edge grouping", () => { 6 | let div: HTMLDivElement; 7 | beforeEach(() => (div = document.createElement("div"))); 8 | 9 | it("Can be disabled by default and then enabled", async () => { 10 | render(, div); 11 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 12 | expect(ref.current?.getEdges().getId()).toEqual([0, 1, 2, 3]); 13 | await act(() => userEvent.click(screen.getByText("toggle"))); 14 | await ref.current?.transformations.afterNextUpdate(); 15 | expect(ref.current?.getEdges().getId()).toEqual([0, 2, `group-1[0-2]`]); 16 | }); 17 | 18 | it("Can be disabled", async () => { 19 | render(, div); 20 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 21 | expect(ref.current?.getEdges().getId()).toEqual([0, 2, `group-1[0-2]`]); 22 | await act(() => userEvent.click(screen.getByText("toggle"))); 23 | await ref.current?.transformations.afterNextUpdate(); 24 | expect(ref.current?.getEdges().getId()).toEqual([0, 1, 2, 3]); 25 | }); 26 | 27 | it("Updates grouping", async () => { 28 | render(, div); 29 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 30 | expect(ref.current?.getEdges().getId()).toEqual([0, 2, `group-1[0-2]`]); 31 | await act(() => userEvent.click(screen.getByText("setGrouping"))); 32 | await ref.current?.transformations.afterNextUpdate(); 33 | expect(ref.current?.getEdges().getId()).toEqual([ 34 | `group-1[0-2]`, 35 | `group-0[0-1]` 36 | ]); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/transformations/neighborGeneration.test.tsx: -------------------------------------------------------------------------------- 1 | import { NeighborGenerationTest, ref } from "./test-components"; 2 | import { render, userEvent, screen } from "../utils"; 3 | import { act } from "react"; 4 | import OgmaLib from "@linkurious/ogma"; 5 | 6 | describe("Neighbor generation", () => { 7 | let div: HTMLDivElement; 8 | beforeEach(() => (div = document.createElement("div"))); 9 | 10 | it("Can be disabled by default and then enabled", async () => { 11 | render(, div); 12 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 13 | expect(ref.current?.getEdges().size).toEqual(0); 14 | await act(() => userEvent.click(screen.getByText("toggle"))); 15 | await ref.current?.transformations.afterNextUpdate(); 16 | expect(ref.current?.getEdges().size).toEqual(2); 17 | }); 18 | 19 | it("Can be disabled", async () => { 20 | render(, div); 21 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 22 | expect(ref.current?.getEdges().size).toEqual(2); 23 | await act(() => userEvent.click(screen.getByText("toggle"))); 24 | await ref.current?.transformations.afterNextUpdate(); 25 | expect(ref.current?.getEdges().size).toEqual(0); 26 | }); 27 | 28 | it("Updates criteria", async () => { 29 | render(, div); 30 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 31 | expect(ref.current?.getEdges().size).toEqual(2); 32 | await act(() => userEvent.click(screen.getByText("setGenerator"))); 33 | await ref.current?.transformations.afterNextUpdate(); 34 | // TODO: bug in ogma, the data is not merged. 35 | //expect(ref.current?.getEdges().size).toEqual(3); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/transformations/neighborMerging.test.tsx: -------------------------------------------------------------------------------- 1 | import { NeighborMergingTest, ref } from "./test-components"; 2 | import { render, userEvent, screen } from "../utils"; 3 | import { act } from "react"; 4 | import OgmaLib from "@linkurious/ogma"; 5 | describe("Neighbor merging", () => { 6 | let div: HTMLDivElement; 7 | beforeEach(() => (div = document.createElement("div"))); 8 | 9 | it("Can be disabled by default and then enabled", async () => { 10 | render(, div); 11 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 12 | expect(ref.current?.getEdges().size).toEqual(2); 13 | await act(() => userEvent.click(screen.getByText("toggle"))); 14 | await ref.current?.transformations.afterNextUpdate(); 15 | expect(ref.current?.getEdges().size).toEqual(1); 16 | }); 17 | 18 | it("Can be disabled", async () => { 19 | render(, div); 20 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 21 | expect(ref.current?.getEdges().size).toEqual(1); 22 | await act(() => userEvent.click(screen.getByText("toggle"))); 23 | await ref.current?.transformations.afterNextUpdate(); 24 | expect(ref.current?.getEdges().size).toEqual(2); 25 | }); 26 | 27 | it("Updates criteria", async () => { 28 | render(, div); 29 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 30 | expect(ref.current?.getEdges().size).toEqual(1); 31 | await act(() => userEvent.click(screen.getByText("setGenerator"))); 32 | await ref.current?.transformations.afterNextUpdate(); 33 | expect(ref.current?.getEdges().size).toEqual(0); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/transformations/nodeCollapsing.test.tsx: -------------------------------------------------------------------------------- 1 | import { NodeCollapsingTest, ref } from "./test-components"; 2 | import { render, userEvent, screen } from "../utils"; 3 | import { act } from "react"; 4 | import OgmaLib from "@linkurious/ogma"; 5 | describe("Node Collapsing", () => { 6 | let div: HTMLDivElement; 7 | beforeEach(() => (div = document.createElement("div"))); 8 | 9 | it("Can be disabled by default and then enabled", async () => { 10 | render(, div); 11 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 12 | expect(ref.current?.getEdges().size).toEqual(2); 13 | await act(() => userEvent.click(screen.getByText("toggle"))); 14 | await ref.current?.transformations.afterNextUpdate(); 15 | expect(ref.current?.getEdges().size).toEqual(1); 16 | }); 17 | 18 | it("Can be disabled", async () => { 19 | render(, div); 20 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 21 | expect(ref.current?.getEdges().size).toEqual(1); 22 | await act(() => userEvent.click(screen.getByText("toggle"))); 23 | await ref.current?.transformations.afterNextUpdate(); 24 | expect(ref.current?.getEdges().size).toEqual(2); 25 | }); 26 | 27 | it("Updates criteria", async () => { 28 | render(, div); 29 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 30 | expect(ref.current?.getEdges().get(0).getData()).toEqual({ 31 | key1: "value1" 32 | }); 33 | await act(() => userEvent.click(screen.getByText("setCollapse"))); 34 | await ref.current?.transformations.afterNextUpdate(); 35 | // bug in ogma, the data is not merged 36 | //TODO: restore this test when the bug is fixed. 37 | // expect(ref.current?.getEdges().get(0).getData()).toEqual({ 38 | // key2: "value2", 39 | // }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/transformations/nodeFilter.test.tsx: -------------------------------------------------------------------------------- 1 | import { NodeFilterTest, ref } from "./test-components"; 2 | import { render, userEvent, screen } from "../utils"; 3 | import { act } from "react"; 4 | import OgmaLib from "@linkurious/ogma"; 5 | describe("Node filter", () => { 6 | let div: HTMLDivElement; 7 | beforeEach(() => (div = document.createElement("div"))); 8 | 9 | it("Can be disabled by default and then enabled", async () => { 10 | render(, div); 11 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 12 | expect(ref.current?.getNodes().getId()).toEqual([0, 1, 2]); 13 | await act(() => userEvent.click(screen.getByText("toggle"))); 14 | await ref.current?.transformations.afterNextUpdate(); 15 | expect(ref.current?.getNodes().getId()).toEqual([0]); 16 | }); 17 | 18 | it("Can be disabled", async () => { 19 | render(, div); 20 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 21 | expect(ref.current?.getNodes().getId()).toEqual([0]); 22 | await act(() => userEvent.click(screen.getByText("toggle"))); 23 | await ref.current?.transformations.afterNextUpdate(); 24 | expect(ref.current?.getNodes().getId()).toEqual([0, 1, 2]); 25 | }); 26 | 27 | it("Updates criteria", async () => { 28 | render(, div); 29 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 30 | expect(ref.current?.getNodes().getId()).toEqual([0]); 31 | await act(() => userEvent.click(screen.getByText("setCriteria"))); 32 | await ref.current?.transformations.afterNextUpdate(); 33 | expect(ref.current?.getNodes().getId()).toEqual([1]); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/transformations/nodeGrouping.test.tsx: -------------------------------------------------------------------------------- 1 | import { NodeGroupingTest, ref } from "./test-components"; 2 | import { render, userEvent, screen } from "../utils"; 3 | import { act } from "react"; 4 | import OgmaLib from "@linkurious/ogma"; 5 | describe("Node grouping", () => { 6 | let div: HTMLDivElement; 7 | beforeEach(() => (div = document.createElement("div"))); 8 | 9 | it("Can be disabled by default and then enabled", async () => { 10 | render(, div); 11 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 12 | expect(ref.current?.getNodes().getId()).toEqual([0, 1, 2]); 13 | await act(() => userEvent.click(screen.getByText("toggle"))); 14 | await ref.current?.transformations.afterNextUpdate(); 15 | expect(ref.current?.getNodes().getId()).toEqual([0, 2, `group-1`]); 16 | }); 17 | 18 | it("Can be disabled", async () => { 19 | render(, div); 20 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 21 | expect(ref.current?.getNodes().getId()).toEqual([0, 2, `group-1`]); 22 | await act(() => userEvent.click(screen.getByText("toggle"))); 23 | await ref.current?.transformations.afterNextUpdate(); 24 | expect(ref.current?.getNodes().getId()).toEqual([0, 1, 2]); 25 | }); 26 | 27 | it("Updates grouping", async () => { 28 | render(, div); 29 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 30 | expect(ref.current?.getNodes().getId()).toEqual([0, 2, `group-1`]); 31 | await act(() => userEvent.click(screen.getByText("setGrouping"))); 32 | await ref.current?.transformations.afterNextUpdate(); 33 | expect(ref.current?.getNodes().getId()).toEqual([`group-1`, `group-0`]); 34 | }); 35 | 36 | it("Triggers callbacks", async () => { 37 | const states = { onEnabled: false, onDestroyed: false, onUpdated: false }; 38 | render( 39 | (states.onEnabled = true)} 41 | onDestroyed={() => (states.onDestroyed = true)} 42 | onUpdated={() => (states.onUpdated = true)} 43 | />, 44 | div 45 | ); 46 | await (ref.current as OgmaLib).transformations.afterNextUpdate(); 47 | expect(states).toEqual({ 48 | onEnabled: true, 49 | onDestroyed: false, 50 | onUpdated: false 51 | }); 52 | await act(() => userEvent.click(screen.getByText("setGrouping"))); 53 | await ref.current?.transformations.afterNextUpdate(); 54 | expect(states).toEqual({ 55 | onEnabled: true, 56 | onDestroyed: false, 57 | onUpdated: true 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/transformations/test-components.tsx: -------------------------------------------------------------------------------- 1 | import graph from "../fixtures/simple_graph.json"; 2 | import graphCurved from "../fixtures/simple_graph_curved.json"; 3 | 4 | import OgmaLib from "@linkurious/ogma"; 5 | 6 | import { 7 | Ogma, 8 | EdgeFilter, 9 | EdgeFilterProps, 10 | NodeFilter, 11 | NodeFilterProps, 12 | EdgeGrouping, 13 | EdgeGroupingProps, 14 | NodeGrouping, 15 | NodeGroupingProps, 16 | NeighborGeneration, 17 | NeighborGenerationProps, 18 | NeighborMerging, 19 | NeighborMergingProps, 20 | NodeCollapsing, 21 | NodeCollapsingProps 22 | } from "../../src"; 23 | import { createRef, forwardRef, useState } from "react"; 24 | 25 | export const ref = createRef(); 26 | 27 | function EdgeFilterTestC( 28 | filter: Partial> = {} 29 | ) { 30 | const [props, setProps] = useState>({ 31 | criteria: (edge) => edge.getId() === 0, 32 | disabled: false, 33 | ...filter 34 | }); 35 | function updateFilter() { 36 | setProps({ 37 | criteria: (edge) => edge.getId() === 1 38 | }); 39 | } 40 | function toggle() { 41 | setProps({ 42 | ...props, 43 | disabled: !props.disabled 44 | }); 45 | } 46 | return ( 47 |
48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 | ); 56 | } 57 | function NodeFilterTestC( 58 | filter: Partial> = {} 59 | ) { 60 | const [props, setProps] = useState>({ 61 | criteria: (node) => node.getId() === 0, 62 | disabled: false, 63 | ...filter 64 | }); 65 | function updateFilter() { 66 | setProps({ 67 | criteria: (node) => node.getId() === 1 68 | }); 69 | } 70 | function toggle() { 71 | setProps({ 72 | ...props, 73 | disabled: !props.disabled 74 | }); 75 | } 76 | return ( 77 |
78 | 79 | 80 | 81 | 82 | 83 | 84 |
85 | ); 86 | } 87 | 88 | function EdgeGroupingTestC( 89 | grouping: Partial> = {} 90 | ) { 91 | const [props, setProps] = useState>({ 92 | selector: (edge) => !!(+edge.getId() % 2), 93 | groupIdFunction: () => `group-1`, 94 | separateEdgesByDirection: false, 95 | generator(_, groupId) { 96 | return { 97 | id: groupId, 98 | data: { key: "value" } 99 | }; 100 | }, 101 | disabled: false, 102 | ...grouping 103 | }); 104 | function updateGrouping() { 105 | setProps({ 106 | ...props, 107 | selector: () => true, 108 | groupIdFunction: (edge) => `group-${+edge.getId() % 2}` 109 | }); 110 | } 111 | function toggle() { 112 | setProps({ 113 | ...props, 114 | disabled: !props.disabled 115 | }); 116 | } 117 | return ( 118 |
119 | 120 | 121 | 122 | 123 | 130 | 131 |
132 | ); 133 | } 134 | 135 | function NodeGroupingTestC( 136 | grouping: Partial> = {} 137 | ) { 138 | const [props, setProps] = useState>({ 139 | selector: (node) => !!(+node.getId() % 2), 140 | groupIdFunction: () => `group-1`, 141 | separateEdgesByDirection: false, 142 | nodeGenerator: (_, groupId) => { 143 | return { 144 | id: groupId, 145 | data: { key: "value" } 146 | }; 147 | }, 148 | disabled: false, 149 | ...grouping 150 | }); 151 | function updateGrouping() { 152 | setProps({ 153 | ...props, 154 | selector: () => true, 155 | groupIdFunction: (node) => `group-${+node.getId() % 2}` 156 | }); 157 | } 158 | function toggle() { 159 | setProps({ 160 | ...props, 161 | disabled: !props.disabled 162 | }); 163 | } 164 | return ( 165 |
166 | 167 | 168 | 169 | 170 | 183 | 184 |
185 | ); 186 | } 187 | 188 | function NeighborGenerationTestC( 189 | generator: Partial> = {} 190 | ) { 191 | const [props, setProps] = useState>( 192 | { 193 | selector: (node) => +node.getId() % 2 === 0, 194 | neighborIdFunction: () => `even`, 195 | disabled: false, 196 | ...generator 197 | } 198 | ); 199 | function updateGenerator() { 200 | setProps({ 201 | ...props, 202 | selector: () => true 203 | }); 204 | } 205 | function toggle() { 206 | setProps({ 207 | ...props, 208 | disabled: !props.disabled 209 | }); 210 | } 211 | return ( 212 |
213 | 214 | 215 | 216 | 217 | 223 | 224 |
225 | ); 226 | } 227 | 228 | function NeighborMergingTestC( 229 | generator: Partial> = {} 230 | ) { 231 | const [props, setProps] = useState>({ 232 | selector: (node) => +node.getId() === 1, 233 | dataFunction: () => ({ value: 1 }), 234 | disabled: false, 235 | ...generator 236 | }); 237 | function updateGenerator() { 238 | setProps({ 239 | ...props, 240 | selector: (node) => +node.getId() === 0 241 | }); 242 | } 243 | function toggle() { 244 | setProps({ 245 | ...props, 246 | disabled: !props.disabled 247 | }); 248 | } 249 | return ( 250 |
251 | 252 | 253 | 254 | 255 | 260 | 261 |
262 | ); 263 | } 264 | 265 | function NodeCollapsingTestC( 266 | generator: Partial> = {} 267 | ) { 268 | const [props, setProps] = useState>({ 269 | selector: (node) => +node.getId() === 0, 270 | edgeGenerator: () => { 271 | return { data: { key1: "value1" } }; 272 | }, 273 | disabled: false, 274 | ...generator 275 | }); 276 | function updateCollapse() { 277 | setProps({ 278 | ...props, 279 | edgeGenerator: () => { 280 | return { data: { key2: "value2" } }; 281 | } 282 | }); 283 | } 284 | function toggle() { 285 | setProps({ 286 | ...props, 287 | disabled: !props.disabled 288 | }); 289 | } 290 | return ( 291 |
292 | 293 | 294 | 295 | 300 | 301 |
302 | ); 303 | } 304 | 305 | export const EdgeFilterTest = forwardRef(EdgeFilterTestC); 306 | export const NodeFilterTest = forwardRef(NodeFilterTestC); 307 | export const EdgeGroupingTest = forwardRef(EdgeGroupingTestC); 308 | export const NodeGroupingTest = forwardRef(NodeGroupingTestC); 309 | export const NeighborGenerationTest = forwardRef(NeighborGenerationTestC); 310 | export const NeighborMergingTest = forwardRef(NeighborMergingTestC); 311 | export const NodeCollapsingTest = forwardRef(NodeCollapsingTestC); 312 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/export */ 2 | import { cleanup, render } from "@testing-library/react"; 3 | import { afterEach } from "vitest"; 4 | 5 | afterEach(() => { 6 | cleanup(); 7 | }); 8 | 9 | // @ts-ignore 10 | const customRender = (ui: React.ReactElement, options = {}) => 11 | render(ui, { 12 | // wrap provider(s) here if needed 13 | wrapper: ({ children }) => children, 14 | ...options 15 | }); 16 | 17 | export * from "@testing-library/react"; 18 | export { default as userEvent } from "@testing-library/user-event"; 19 | // override render export 20 | export { customRender as render }; 21 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationMap": true 6 | }, 7 | "include": ["src/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "moduleResolution": "node", 5 | "allowJs": true, 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "sourceMap": true, 9 | "resolveJsonModule": true, 10 | "strict": true, 11 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 12 | "strictNullChecks": true /* Enable strict null checks. */, 13 | "strictFunctionTypes": true /* Enable strict checking of function types. */, 14 | "noUnusedLocals": true /* Report errors on unused locals. */, 15 | "noUnusedParameters": true /* Report errors on unused parameters. */, 16 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 17 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 18 | "importHelpers": true, 19 | "skipLibCheck": false, 20 | "jsx": "react-jsx", 21 | "outDir": "./dist/", 22 | "types": ["node", "jest"], 23 | "lib": ["ES6", "DOM"] 24 | }, 25 | "include": [ 26 | "src/**/*.ts", 27 | "src/**/*.tsx", 28 | "next-env.d.ts", 29 | "**/*.ts", 30 | "**/*.tsx" 31 | ], 32 | "exclude": ["node_modules"] 33 | } 34 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import { defineConfig } from "vite"; 5 | import react from "@vitejs/plugin-react"; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | react({ 11 | jsxRuntime: "classic" 12 | }) 13 | ], 14 | define: { "process.env": { NODE_ENV: "production" } }, 15 | build: { 16 | minify: true, 17 | sourcemap: true, 18 | lib: { 19 | name: "OgmaReact", 20 | entry: "./src/index.ts", 21 | formats: ["es", "cjs", "umd"], 22 | fileName: (format) => { 23 | if (format === "umd") return `index.umd.js`; 24 | if (format === "cjs") return `index.cjs`; 25 | return `index.mjs`; 26 | } 27 | }, 28 | rollupOptions: { 29 | external: ["@linkurious/ogma", "react", "react-dom", "react-dom/server"], 30 | output: { 31 | globals: { 32 | react: "React", 33 | "react-dom": "ReactDOM", 34 | "react-dom/server": "ReactDOMServer", 35 | "@linkurious/ogma": "Ogma" 36 | } 37 | } 38 | } 39 | }, 40 | esbuild: { 41 | jsxInject: "import React from 'react';", 42 | jsx: "automatic" 43 | }, 44 | test: { 45 | globals: true, 46 | environment: "jsdom", 47 | setupFiles: "./test/setup.ts", 48 | coverage: { 49 | reporter: ["json", "cobertura"], 50 | include: ["src/**/*.{ts,tsx}"], 51 | all: true, 52 | reportsDirectory: "reports/coverage" 53 | } 54 | } 55 | }); 56 | --------------------------------------------------------------------------------