├── .babelrc ├── .circleci └── config.yml ├── .eslintrc ├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore ├── .prettierrc ├── DEVELOPER.md ├── LICENSE ├── README.md ├── demo ├── demo.css ├── index.html ├── index.js ├── script-tag.html └── script-tag.js ├── docs └── spec.md ├── jest.config.js ├── package-lock.json ├── package.json ├── publish-demo.sh ├── src ├── codelists │ └── interestTypes.js ├── images │ ├── bovs-arrangement.svg │ ├── bovs-entity-unknown.svg │ ├── bovs-listed.svg │ ├── bovs-organisation.svg │ ├── bovs-person-unknown.svg │ ├── bovs-person.svg │ ├── bovs-state.svg │ ├── bovs-statebody.svg │ └── bovs-unknown.svg ├── index.js ├── model │ ├── edges │ │ └── edges.js │ └── nodes │ │ ├── nodeSVGLabel.js │ │ └── nodes.js ├── parse │ └── parse.js ├── render │ ├── renderD3.js │ ├── renderGraph.js │ └── renderUI.js ├── style.css └── utils │ ├── bezierBuilder.js │ ├── bods.js │ ├── curve.js │ ├── sanitiser.js │ ├── svgTools.js │ └── svgsaver.js ├── tests ├── __mocks__ │ ├── dataMock.json │ ├── edgeMock.js │ └── styleMock.js ├── bods.test.js ├── edges.test.js ├── html.test.js ├── nodes.test.js ├── parse.test.js ├── renderD3.test.js └── sanitiser.test.js ├── webpack.demo.config.js ├── webpack.dev.config.js └── webpack.library.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["@babel/plugin-transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | node: circleci/node@4.0.0 4 | slack: circleci/slack@3.4.2 5 | jobs: 6 | lint: 7 | executor: node/default 8 | steps: 9 | - checkout 10 | - node/install-packages: 11 | cache-path: ~/project/node_modules 12 | override-ci-command: npm install 13 | - run: npm run lint 14 | - slack/status: 15 | fail_only: true 16 | webhook: webhook 17 | workflows: 18 | lint: 19 | jobs: 20 | - lint 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2018, 4 | "sourceType": "module" 5 | }, 6 | 7 | "env": { 8 | "es6": true 9 | }, 10 | "extends": ["prettier"], 11 | "plugins": ["prettier"], 12 | "rules": { 13 | "prettier/prettier": ["error"] 14 | }, 15 | "parser": "@babel/eslint-parser" 16 | } 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | dev-build 4 | demo-build 5 | coverage 6 | sample 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "printWidth": 110 6 | } -------------------------------------------------------------------------------- /DEVELOPER.md: -------------------------------------------------------------------------------- 1 | # Developer Guide 2 | 3 | ## Development 4 | To build the project for development using the webpack server run: 5 | 6 | ``` 7 | npm i 8 | npm start 9 | ``` 10 | 11 | ## Demo Page 12 | To build the demo page, which uses the templates found in `/demo`, run: 13 | 14 | ``` 15 | npm i 16 | npm run demo 17 | ``` 18 | This will output the compiled code, including the demo page, to `demo-build`. 19 | 20 | To build and push a new version to gh-pages, run `publish-demo.sh`. 21 | 22 | ## Libary compilation 23 | To build the library without the demo page run: 24 | 25 | ``` 26 | npm i 27 | npm run library 28 | ``` 29 | This will compile the javascript into `dist/main.js` and will include all of the required SVG files in `/dist/images` 30 | 31 | ## Publishing 32 | To publish this to NPM (assuming you're logged in through the cli and have 33 | permissions on the project): 34 | 35 | `$ npm publish --access public` 36 | 37 | ## Code Guide 38 | 39 | The access point to the library is [index.js](./src/index.js), which contains a `draw()` function. This function is central to bringing the rest of the code together. 40 | 41 | The remaining code is structured into three phases, **parsing**, **modelling** and **rendering**, as well as some utility functions. 42 | 43 | - [Parsing](./src/parse/parse.js) happens within the demo [index.js](./demo/index.js), as soon as the data is input, before the `draw()` function is called. 44 | - Modelling occurs at the beginning of the `draw()` function, and generates the objects required to draw the graph by mapping the BODS data to [nodes](./src/model/nodes/nodes.js) and to [edges](./src/model/edges/edges.js). 45 | - Rendering of the [graph](./src/render/renderGraph.js) occurs after the data has been modelled. After building and drawing the graph we then apply extensive customisation to the [D3 graph](./src/render/renderD3.js). Further [UI elements](./src/render/renderUI.js) are rendered at the end of the `draw()` function. 46 | 47 | See code comments for more context. 48 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BODS-Dagre 2 | 3 | BODS-Dagre is a small javascript library for automatic generation of directed 4 | graphs representing Beneficial Ownership Data for the web. It relies on d3, 5 | dagre-d3 and bezier-js to do all the heavy lifting. 6 | 7 | The tool accepts [Beneficial Ownership Data Standard (BODS)](http://standard.openownership.org/) 8 | JSON as an input and outputs SVG content on a webpage which contains the 9 | appropriate placeholder HTML. 10 | 11 | For a hosted version you can use directly, see [our main website's installation](https://www.openownership.org/en/publications/beneficial-ownership-visualisation-system/bods-data-visualiser/) 12 | 13 | ## Installation & Usage 14 | 15 | Through your package manager: 16 | 17 | ```shell 18 | $ npm install @openownership/bods-dagre 19 | ``` 20 | 21 | Or as a script tag: 22 | 23 | ```html 24 | 25 | ``` 26 | 27 | Then in your application: 28 | 29 | ```js 30 | // Your BODS data 31 | const data = JSON.parse('some JSON string'); 32 | // Where you want the SVG drawn 33 | const container = document.getElementById('some-id'); 34 | // Where you're serving the bundled images from (see below) 35 | const imagesPath = '/some/folder'; 36 | // Set a node limit above which labels will no longer be shown 37 | const labelLimit = 8 38 | 39 | BODSDagre.draw(data, container, imagesPath); 40 | ``` 41 | 42 | Full demo applications are hosted from this repo: 43 | - Using webpack 44 | - Using a script tag 45 | 46 | ### Images 47 | 48 | The library includes license-free SVG images for world flags, as well as some 49 | generic icons from our [Beneficial Ownership Visualisation System](https://www.openownership.org/en/publications/beneficial-ownership-visualisation-system/). 50 | 51 | You can find these inside the dist/images folder, but you need to make them 52 | available at some URL path in your application and tell the library what that 53 | path is when you call it. 54 | 55 | ### Zoom 56 | 57 | The functionality has been included for two zoom buttons. These must be added to 58 | the page, along with the placeholder, with the correct IDs for the code to 59 | pick them up: 60 | 61 | ```html 62 | 63 | 64 | ``` 65 | 66 | ### Save 67 | 68 | The functionality has been included for two save buttons. These must be added to 69 | the page, along with the placeholder, with the correct IDs for the code to 70 | pick them up: 71 | 72 | ```html 73 | 74 | 75 | ``` 76 | 77 | It should be noted that the PNG output will provide an image that is scaled to the canvas. The resolution is likely to be poor. 78 | 79 | The SVG download button provides the complete graph in the SVG markup format. 80 | 81 | ## Node types 82 | 83 | At present the tool provides visualisation of the following entity types (based on [`personStatements` and `entityStatements`](https://standard.openownership.org/en/0.2.0/schema/reference.html#schema-entity-statement)): 84 | 85 | * knownPerson 86 | * anonymousPerson 87 | * unknownPerson 88 | * registeredEntity 89 | * registeredEntityListed 90 | * legalEntity 91 | * arrangement 92 | * anonymousEntity 93 | * unknownEntity 94 | * state 95 | * stateBody 96 | 97 | If the entity type is not recognised then it will default to the unknown type. 98 | 99 | ## Country flags 100 | 101 | If the country is not present or is not recognised then no flag will be displayed. 102 | 103 | ## Development 104 | 105 | Please see the [developer guide](./DEVELOPER.md) for more information. 106 | 107 | ## Specification 108 | 109 | Please see the [visualiser specification](docs/spec.md) for an outline of functionality and requirements. 110 | -------------------------------------------------------------------------------- /demo/demo.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | button { 6 | margin: 10px; 7 | width: 200px; 8 | height: 40px; 9 | } 10 | 11 | #svg-holder { 12 | cursor: grab; 13 | position: relative; 14 | text-align: center; 15 | border: 1px solid #000; 16 | min-height: 400px; 17 | } 18 | 19 | #svg-holder:active { 20 | cursor: grabbing; 21 | } 22 | 23 | #bods-svg { 24 | width: 100%; 25 | min-height: 400px; 26 | margin-left: auto; 27 | margin-right: auto; 28 | } 29 | 30 | #result { 31 | height: 100px; 32 | width: 100%; 33 | } 34 | 35 | .main-article { 36 | max-width: 800px; 37 | margin-left: auto; 38 | margin-right: auto; 39 | text-align: center; 40 | padding: 0 40px; 41 | } 42 | 43 | .file-input { 44 | text-align: center; 45 | } 46 | 47 | .edgePath, 48 | .node { 49 | cursor: pointer; 50 | } 51 | 52 | .node.unspecified, 53 | .node.unknown { 54 | cursor: default; 55 | } 56 | 57 | #disclosure-widget details { 58 | text-align: left; 59 | border: 1px solid #000; 60 | padding: 10px; 61 | margin: 10px 0 10px 0; 62 | overflow: scroll; 63 | } 64 | 65 | #slider-container { 66 | display: none; 67 | border: 1px solid #000; 68 | margin: 10px 0 10px 0; 69 | width: 100%; 70 | } 71 | 72 | #slider-input { 73 | width: calc(100% - 20px); 74 | margin: 10px auto 0 auto; 75 | } 76 | 77 | #slider-input:disabled { 78 | cursor: not-allowed; 79 | } 80 | 81 | #slider-input:active { 82 | cursor: grabbing; 83 | } 84 | 85 | #slider-input:disabled::-webkit-slider-thumb { 86 | cursor: not-allowed; 87 | } 88 | 89 | #slider-input:disabled::-moz-range-thumb { 90 | cursor: not-allowed; 91 | } 92 | 93 | #slider-input:disabled::-ms-thumb { 94 | cursor: not-allowed; 95 | } 96 | 97 | #slider-input::-webkit-slider-thumb { 98 | cursor: grab; 99 | } 100 | 101 | #slider-input::-moz-range-thumb { 102 | cursor: grab; 103 | } 104 | 105 | #slider-input::-ms-thumb { 106 | cursor: grab; 107 | } 108 | #slider-input::-webkit-slider-thumb:active { 109 | cursor: grabbing; 110 | } 111 | 112 | #slider-input::-moz-range-thumb:active { 113 | cursor: grabbing; 114 | } 115 | 116 | #slider-input::-ms-thumb:active { 117 | cursor: grabbing; 118 | } 119 | 120 | .button-container { 121 | text-align: right; 122 | } 123 | 124 | .tippy-content pre { 125 | overflow: scroll; 126 | } 127 | 128 | .close-tooltip { 129 | width: 40px; 130 | } 131 | 132 | #draw-vis { 133 | margin: 10px auto; 134 | width: 100%; 135 | } 136 | 137 | #zoom_in { 138 | position: absolute; 139 | top: 20px; 140 | right: 70px; 141 | width: 40px; 142 | } 143 | 144 | #zoom_out { 145 | position: absolute; 146 | top: 20px; 147 | right: 20px; 148 | width: 40px; 149 | } 150 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BODS Data Visualisation Demo 6 | 7 | 8 |
9 |

BODS Data Visualisation Demo

10 |
11 |
12 | 13 | 14 |
15 |
16 | 17 | 18 |
19 |
20 | 21 | 22 | 23 |
24 |
25 |
26 | 27 | 28 |
29 |

Data requirements

30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | import { selectData } from '../src/index.js'; 2 | import { clearSVG } from '../src/utils/svgTools.js'; 3 | import { parse } from '../src/parse/parse.js'; 4 | import './demo.css'; 5 | 6 | const clearDrawing = () => { 7 | clearSVG(document.getElementById('svg-holder')); 8 | }; 9 | 10 | // Read file asynchronously 11 | const readFile = (file) => { 12 | return new Promise((resolve) => { 13 | const fr = new FileReader(); 14 | fr.onload = function (e) { 15 | resolve(e.target.result); 16 | }; 17 | fr.readAsText(file); 18 | }); 19 | }; 20 | 21 | const getJSON = async () => { 22 | clearDrawing(); 23 | let data; 24 | var files = document.getElementById('selectFiles').files; 25 | 26 | if (files.length <= 0) { 27 | // Parse inline data 28 | data = parse(document.getElementById('result').value); 29 | } else { 30 | // Parse file data 31 | const file = await readFile(files.item(0)); 32 | data = parse(file); 33 | } 34 | 35 | visualiseData(data); 36 | }; 37 | 38 | const visualiseData = (data) => { 39 | // Render data as text 40 | if (data.formatted) { 41 | document.getElementById('result').value = data.formatted; 42 | } 43 | // Select data and render as graph 44 | selectData({ 45 | data: data.parsed, 46 | container: document.getElementById('svg-holder'), 47 | imagesPath: 'images', 48 | labelLimit: 100, 49 | useTippy: true, 50 | }); 51 | }; 52 | 53 | window.onload = () => { 54 | document.getElementById('svg-clear').addEventListener('click', clearDrawing, true); 55 | document.getElementById('import').addEventListener('click', getJSON, true); 56 | document.getElementById('draw-vis').addEventListener('click', getJSON, true); 57 | }; 58 | -------------------------------------------------------------------------------- /demo/script-tag.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BODS Data Visualisation Demo 6 | 7 | 8 | 9 |
10 |

BODS Data Visualisation Demo

11 |
12 |
13 | 14 | 15 |
16 |
17 | 18 | 19 |
20 |
21 | 22 | 23 | 24 |
25 |
26 |
27 |
28 |

Data requirements

29 |
30 |
31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /demo/script-tag.js: -------------------------------------------------------------------------------- 1 | var clearDrawing = function () { 2 | const svg = document.getElementById('bods-svg'); 3 | if (svg) { 4 | svg.remove(); 5 | } 6 | }; 7 | 8 | const parse = (data) => { 9 | let parsed; 10 | 11 | // Check data is valid JSON 12 | try { 13 | parsed = JSON.parse(data); 14 | } catch (error) { 15 | console.error(error); 16 | return {}; 17 | } 18 | 19 | // Format JSON consistently 20 | const formatted = JSON.stringify(parsed, null, 2); 21 | 22 | // Return parsed and formatted JSON 23 | return { 24 | formatted, 25 | parsed: JSON.parse(formatted), 26 | }; 27 | }; 28 | 29 | // Read file asynchronously 30 | const readFile = (file) => { 31 | return new Promise((resolve) => { 32 | const fr = new FileReader(); 33 | fr.onload = function (e) { 34 | resolve(e.target.result); 35 | }; 36 | fr.readAsText(file); 37 | }); 38 | }; 39 | 40 | const getJSON = async () => { 41 | clearDrawing(); 42 | let data; 43 | var files = document.getElementById('selectFiles').files; 44 | 45 | if (files.length <= 0) { 46 | // Parse inline data 47 | data = parse(document.getElementById('result').value); 48 | } else { 49 | // Parse file data 50 | const file = await readFile(files.item(0)); 51 | data = parse(file); 52 | } 53 | 54 | visualiseData(data); 55 | }; 56 | 57 | const visualiseData = (data) => { 58 | // Render data as text 59 | document.getElementById('result').value = data.formatted; 60 | // Render data as graph 61 | BODSDagre.selectData({ 62 | data: data.parsed, 63 | container: document.getElementById('svg-holder'), 64 | imagesPath: 'images', 65 | labelLimit: 100, 66 | useTippy: true, 67 | }); 68 | }; 69 | 70 | window.onload = () => { 71 | document.getElementById('svg-clear').addEventListener('click', clearDrawing, true); 72 | document.getElementById('import').addEventListener('click', getJSON, true); 73 | document.getElementById('draw-vis').addEventListener('click', getJSON, true); 74 | }; 75 | -------------------------------------------------------------------------------- /docs/spec.md: -------------------------------------------------------------------------------- 1 | # BODS visualisation library specification 2 | 3 | This document outlines the functionality and requirements of the [BODS visualisation library](https://github.com/openownership/visualisation-tool). 4 | 5 | Refer to the full [BODS documentation](https://standard.openownership.org/en/latest) for more information (and the associated [Github repository](https://github.com/openownership/data-standard)). 6 | 7 | ## Data requirements for visual features 8 | 9 | The visualisation library requires JSON data with a minimum set of data fields to produce a directed graph. It is not necessary for the supplied JSON data to be valid BODS data, but it must meet some minimum requirements. 10 | 11 | The following sections and tables show the visual features rendered by the tool and what fields they require in the given format of data. Any schema fields that are not present in the tables below are not currently used in the generation of graphs by the visualiser tool and will be ignored. 12 | 13 | ### General 14 | 15 | The format of the data presented to the tool: 16 | 17 | - MUST be a valid JSON array of objects 18 | - is presumed to be BODS 0.4-like, unless the first object in the JSON array has a `publicationDetails.bodsVersion` value of '0.2' or '0.3', in which case all objects will be presumed to be BODS 0.2/0.3-like. 19 | - is presumed to have a time dimension (that is to show the properties of people, entities and relationships changing over a period of time) if: 20 | - In BODS 0.2 or 0.3-like data, the `replacesStatements` field is present in any object and contains a value corresponding to the `statementID` value of a different object (both objects having the same `statementType` value). 21 | - In BODS 0.4-like data, there are multiple objects which share both a `recordId` value and a `recordType` value. 22 | 23 | The list of JSON objects presented to the tool is first filtered to represent a snapshot in time (if the data has a time dimension). Then it is processed to draw person nodes, entity nodes and relationship edges. 24 | 25 | ### Filtering data to produce a snapshot in time 26 | 27 | #### BODS 0.2 or 0.3-like data 28 | 29 | Only the latest data in a dataset is retained and rendered. If an object (X) has a `statementID` value which appears in the `replacesStatements` array of any other object in the dataset AND both objects have the `statementType` (of 'ownershipOrControlStatement', 'entityStatement', or 'personStatement') then X is filtered out. 30 | 31 | #### BODS 0.4-like data 32 | 33 | A timepoint is a date (not a date-time). To produce a snapshot for a timepoint, T, we take the following approach: 34 | 35 | 1. Objects are grouped, which have both matching `recordId` and `recordType` values. (Where `recordType` is one of 'entity', 'person', or 'relationship'.) 36 | 2. In each group: 37 | - for all objects with a `statementDate` value, if `statementDate` > T then filter out the object. 38 | - for all objects with a `statementDate` value, filter out all objects except one with the largest value. 39 | - if no objects have a `statementDate` value, filter out all but one object. Otherwise filter out all objects without a `statementDate`. 40 | 3. Only one object will be left in each group. These, plus any initial singleton objects are then filtered as follows: 41 | - If the object has `recordStatus` 'closed', filter it out. 42 | - If the object has `recordType` 'entity' and a `dissolutionDate` <= T, filter it out. 43 | - If the object has `recordType` 'person' and a `deathDate` <= T, filter it out. 44 | - If the object has `recordType` 'relationship', filter out all objects in the `interests` array where `endDate` <= T. 45 | 46 | ### Processing objects to render visual features 47 | In the following tables, a feature may span multiple rows. In these cases, the conditions of the fields and their values in ALL the rows must be met in order for the feature to be rendered. 48 | #### Person Nodes 49 | 50 | | Feature | BODS 0.4 field | Value(s) | Required field? | BODS 0.2/0.3 field | Value(s) | Required field? | 51 | | --- | --- | --- | --- | --- | --- | --- | 52 | | Basic node drawn | `recordType` | 'person' | yes | `statementType` | 'personStatement' | yes | 53 | | | `recordStatus` | Not 'closed' | no | | | | 54 | | Node label drawn (default is 'Unknown person') | `recordDetails.names[]`\* | Value of 'fullName' or compound of other field values in name object | no | `names[]`* | Value of 'fullName' or compound of other field values in name object | no | 55 | | Node icon drawn (default is unknown person icon) | `recordDetails.personType` | 'anonymousPerson', 'unknownPerson' or 'knownPerson' | no | `personType` | 'anonymousPerson', 'unknownPerson' or 'knownPerson' | no | 56 | | Country flag drawn (default is no flag) | `recordDetails.nationalities[0].code` | The 2-letter country code (ISO 3166-1) or the subdivision code (ISO 3166-2) for the jurisdiction | no | `nationalities[0].code` | The 2-letter country code (ISO 3166-1) or the subdivision code (ISO 3166-2) for the jurisdiction | no | 57 | | Node is connectable | `recordId` | Any string | no | `statementId` | Any string | no | 58 | 59 | \* The name types will be used in the following order: 'individual', 'transliteration', 'alternative', 'birth', 'translation', 'former'. 60 | 61 | #### Entity Nodes 62 | 63 | | Feature | BODS 0.4 field | Value(s) | Required field? | BODS 0.2/0.3 field | Value(s) | Required field? | 64 | | --- | --- | --- | --- | --- | --- | --- | 65 | | Basic node drawn | `recordType` | 'entity' | yes | `statementType` | 'entityStatement' | yes | 66 | | | `recordStatus` | not 'closed' | no | | | | 67 | | Node label drawn (default is none) | `recordDetails.name` | Any string | no | `name` | Any string | no | 68 | | Node icon drawn (default is unknown entity icon) | `recordDetails.entityType.type` | 'registeredEntity', 'legalEntity', 'arrangement', 'anonymousEntity', 'unknownEntity', 'state' or 'stateBody' | no | `entityType` | 'registeredEntity', 'legalEntity', 'arrangement', 'anonymousEntity', 'unknownEntity', 'state' or 'stateBody' | no | 69 | | Public company node icon drawn | `recordDetails.publicListing.hasPublicListing` | `true` | no | `publicListing.hasPublicListing` | `true` | no | 70 | | Country flag drawn (default is no flag) | `recordDetails.jurisdiction.code` | The 2-letter country code (ISO 3166-1) or the subdivision code (ISO 3166-2) for the jurisdiction | no | `incorporatedInJurisdiction.code` (BODS v0.2) or `jurisdiction.code` (BODS v0.3) | The 2-letter country code (ISO 3166-1) or the subdivision code (ISO 3166-2) for the jurisdiction | no | 71 | | Node is connectable | `recordId` | Any string | no | `statementId` | Any string | no | 72 | 73 | #### Edges 74 | 75 | | Feature | BODS 0.4 field | Value(s) | Required field? | BODS 0.2/0.3 field | Value(s) | Required field? | 76 | | --- | --- | --- | --- | --- | --- | --- | 77 | | Edge is drawn (black dotted line, no label) | `recordType` | 'relationship' | yes | `statementType` | 'ownershipOrControlStatement' | yes | 78 | | | `recordStatus` | not 'closed' | no | | | | 79 | | | `recordDetails.subject` or `recordDetails.interestedParty` | `recordId` of a connectable node | yes | `subject` or `interestedParty` | `statementId` of a connectable node | yes | 80 | | Unspecified node drawn and connected as interested party with label 'Unspecified' | `recordDetails.interestedParty` | type is object | no | `interestedParty.unspecified` | Any | no | 81 | | Unspecified node drawn and connected as subject with label 'Unspecified' | `recordDetails.subject` | type is object | no | n/a | n/a | n/a | 82 | | Unknown node drawn and connected as interested party | `recordDetails.interestedParty` field is missing or `interestedParty` is not a `recordId` string matching a connectable node | n/a | no | `interestedParty` field is missing or `interestedParty.describedBy[...]Statement` is not a `statementId` string matching a connectable node | n/a | no | 83 | | Unknown node drawn and connected as subject | `recordDetails.subject` field is missing or `subject` is not a `recordId` string matching a connectable node | n/a | no | `subject.describedByEntityStatement` field is missing or `subject.describedByEntityStatement` is not a `statementId` string matching a connectable node | n/a | no | 84 | | Edge line added: purple with 'owns' label (default is dotted) | `recordDetails.interests[].interestType` | At least one interest has `interestType` in the 'ownership' category** | no | `interests[].interestType` | At least one interest has `interestType` in the 'ownership' category** | no | 85 | | ...line is made solid | `recordDetails.interests[].directOrIndirect` | At least one interest in the 'ownership' category** has value 'direct' | no | `interests[].interestLevel` (BODS v0.2) or `interests[].directOrIndirect` (BODS v0.3) | At least one interest in the 'ownership' category** has value 'direct' | no | 86 | | Edge line added: light blue with 'controls' label (default is dotted) | `recordDetails.interests[].interestType` | At least one interest has `interestType` in the 'control' category** | no | `interests[].interestType` | At least one interest has `interestType` in the 'control' category** | no | 87 | | ...line is made solid | `recordDetails.interests[].directOrIndirect` | At least one interest in the 'control' category** has value 'direct' | no | `interests[].interestLevel` (BODS v0.2) or `interests[].directOrIndirect` (BODS v0.3) | At least one interest in the 'control' category** has value 'direct' | no | 88 | | Black edge is made solid | `recordDetails.interests[].directOrIndirect` | At least one interest not in the 'ownership' or 'control' category** has value 'direct' | no | `interests[].interestLevel` (BODS v0.2) or `interests[].directOrIndirect` (BODS v0.3) | At least one interest not in the 'ownership' or 'control' category** has value 'direct' | no | 89 | | Thickness of 'owns' and 'controls' lines (plus label containing value, where it exists) | `recordDetails.interests[].share`, if `interestType` is 'shareholding' or 'votingRights' | Exact value takes precedence over a min-max range | no | `recordDetails.interests[].share`, if `interestType` is 'shareholding' or 'votingRights' | Exact value takes precedence over a min-max range | no | 90 | 91 | \** The following table details which `interestType`s fall into which category: 92 | 93 | | `interestType` | BODS 0.3 | BODS 0.4 | Category | 94 | | --- | --- | --- | --- | 95 | | `shareholding` | x | x | ownership | 96 | | `votingRights` | x | x | control | 97 | | `appointmentOfBoard` | x | x | control | 98 | | `otherInfluenceOrControl` | x | x | control | 99 | | `seniorManagingOfficial` | x | x | control | 100 | | `settlor` | x | x | control | 101 | | `trustee` | x | x | control | 102 | | `protector` | x | x | control | 103 | | `beneficiaryOfLegalArrangement` | x | x | ownership | 104 | | `rightsToSurplusAssetsOnDissolution` | x | x | ownership | 105 | | `rightsToProfitOrIncome` | x | x | ownership | 106 | | `rightsGrantedByContract` | x | x | control | 107 | | `conditionalRightsGrantedByContract` | x | x | control | 108 | | `controlViaCompanyRulesOrArticles` | x | x | control | 109 | | `controlByLegalFramework` | x | x | control | 110 | | `boardMember` | x | x | control | 111 | | `boardChair` | x | x | control | 112 | | `unknownInterest` | x | x | - | 113 | | `unpublishedInterest` | x | x | - | 114 | | `enjoymentAndUseOfAssets` | x | x | ownership | 115 | | `rightToProfitOrIncomeFromAssets` | x | x | ownership | 116 | | `nominee` | | x | control | 117 | | `nominator` | | x | control | 118 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | export default { 3 | collectCoverage: false, 4 | collectCoverageFrom: ['**/src/**'], 5 | verbose: true, 6 | moduleNameMapper: { 7 | '\\.(css|sass|scss)$': '/tests/__mocks__/styleMock.js', 8 | d3: '/node_modules/d3/dist/d3.min.js', 9 | }, 10 | setupFilesAfterEnv: ['jest-extended/all'], 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openownership/bods-dagre", 3 | "version": "0.4.0", 4 | "files": [ 5 | "dist" 6 | ], 7 | "main": "dist/bods-dagre.js", 8 | "repository": "github:openownership/visualisation-tool", 9 | "license": "Apache License 2.0", 10 | "type": "module", 11 | "dependencies": { 12 | "@iconfu/svg-inject": "^1.2.3", 13 | "bezier-js": "^2.6.1", 14 | "canvas-toBlob": "^1.0.0", 15 | "compare-versions": "^6.1.1", 16 | "computed-styles": "^1.1.2", 17 | "d3": "^7.9.0", 18 | "dagre-d3-es": "^7.0.10", 19 | "file-saver": "^2.0.5", 20 | "flag-icons": "6.1.1", 21 | "tippy.js": "^6.3.7" 22 | }, 23 | "scripts": { 24 | "demo": "webpack --config=webpack.demo.config.js", 25 | "dev": "webpack --config=webpack.dev.config.js", 26 | "library": "webpack --config=webpack.library.config.js", 27 | "prepublishOnly": "npm run library", 28 | "test": "jest", 29 | "lint": "eslint src", 30 | "start": "webpack-dev-server --open --config=webpack.dev.config.js" 31 | }, 32 | "browserslist": [ 33 | "defaults" 34 | ], 35 | "devDependencies": { 36 | "@babel/core": "^7.11.4", 37 | "@babel/eslint-parser": "^7.18.2", 38 | "@babel/plugin-transform-runtime": "^7.11.0", 39 | "@babel/preset-env": "^7.11.0", 40 | "babel-loader": "^8.1.0", 41 | "clean-webpack-plugin": "^3.0.0", 42 | "copy-webpack-plugin": "^6.1.0", 43 | "css-loader": "^6.8.1", 44 | "eslint": "^7.19.0", 45 | "eslint-config-prettier": "^6.11.0", 46 | "eslint-plugin-prettier": "^3.1.3", 47 | "file-loader": "^6.0.0", 48 | "html-webpack-plugin": "^5.6.0", 49 | "jest": "^29.7.0", 50 | "jest-environment-jsdom": "^29.7.0", 51 | "jest-extended": "^4.0.2", 52 | "prettier": "^2.0.5", 53 | "style-loader": "^1.2.1", 54 | "terser-webpack-plugin": "^5.3.10", 55 | "vitest": "^2.1.9", 56 | "webpack": "^5.94.0", 57 | "webpack-bundle-analyzer": "^4.9.0", 58 | "webpack-cli": "^4.2.0", 59 | "webpack-dev-server": "^4.15.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /publish-demo.sh: -------------------------------------------------------------------------------- 1 | set -euf -o pipefail 2 | rm -rf demo-build 3 | git worktree add demo-build gh-pages 4 | npm run demo 5 | cd demo-build 6 | git add --all 7 | git commit -m "New demo build [ci skip]" 8 | git push origin gh-pages 9 | cd .. 10 | git worktree remove demo-build 11 | -------------------------------------------------------------------------------- /src/codelists/interestTypes.js: -------------------------------------------------------------------------------- 1 | // This is currently a mix of the interestType codelists from v0.3 and earlier 2 | // N.B. Not respresentative of a single BODS version. This is to ensure backwards compatibility. 3 | export default { 4 | shareholding: { 5 | description: 'Shareholding', 6 | category: 'ownership', 7 | }, 8 | votingRights: { 9 | description: 'Voting rights', 10 | category: 'control', 11 | }, 12 | appointmentOfBoard: { 13 | description: 'Appointment of board', 14 | category: 'control', 15 | }, 16 | otherInfluenceOrControl: { 17 | description: 'Other influence or control', 18 | category: 'control', 19 | }, 20 | seniorManagingOfficial: { 21 | description: 'Senior managing official', 22 | category: 'control', 23 | }, 24 | settlor: { 25 | description: 'Settlor', 26 | category: 'control', 27 | }, 28 | trustee: { 29 | description: 'Trustee', 30 | category: 'control', 31 | }, 32 | protector: { 33 | description: 'Protector', 34 | category: 'control', 35 | }, 36 | beneficiaryOfLegalArrangement: { 37 | description: 'Beneficiary of a legal arrangement', 38 | category: 'ownership', 39 | }, 40 | rightsToSurplusAssetsOnDissolution: { 41 | description: 'Rights to surplus assets on dissolution', 42 | category: 'ownership', 43 | }, 44 | rightsToProfitOrIncome: { 45 | description: 'Rights to receive profits or income', 46 | category: 'ownership', 47 | }, 48 | rightsGrantedByContract: { 49 | description: 'Rights granted by contract', 50 | category: 'control', 51 | }, 52 | conditionalRightsGrantedByContract: { 53 | description: 'Conditional rights granted by contract', 54 | category: 'control', 55 | }, 56 | controlViaCompanyRulesOrArticles: { 57 | description: 'Control via company rules or articles', 58 | category: 'control', 59 | }, 60 | controlByLegalFramework: { 61 | description: 'Control by legal framework', 62 | category: 'control', 63 | }, 64 | boardMember: { 65 | description: 'Board member', 66 | category: 'control', 67 | }, 68 | boardChair: { 69 | description: 'Board chair', 70 | category: 'control', 71 | }, 72 | unknownInterest: { 73 | description: 'Unknown interest', 74 | category: '', 75 | }, 76 | unpublishedInterest: { 77 | description: 'Unpublished interest', 78 | category: '', 79 | }, 80 | enjoymentAndUseOfAssets: { 81 | description: 'Enjoyment and use of assets', 82 | category: 'ownership', 83 | }, 84 | rightToProfitOrIncomeFromAssets: { 85 | description: 'Right to profit or income from assets', 86 | category: 'ownership', 87 | }, 88 | nominee: { 89 | description: 'Nominee', 90 | category: 'control', 91 | }, 92 | nominator: { 93 | description: 'Nominator', 94 | category: 'control', 95 | }, 96 | 'voting-rights': { 97 | description: 'Voting rights', 98 | category: 'control', 99 | }, 100 | 'appointment-of-board': { 101 | description: 'Appointment of board', 102 | category: 'control', 103 | }, 104 | 'other-influence-or-control': { 105 | description: 'Other influence or control', 106 | category: 'control', 107 | }, 108 | 'senior-managing-official': { 109 | description: 'Senior managing official', 110 | category: 'control', 111 | }, 112 | 'beneficiary-of-legal-arrangement': { 113 | description: 'Beneficiary of a legal arrangement', 114 | category: 'ownership', 115 | }, 116 | 'rights-to-surplus-assets-on-dissolution': { 117 | description: 'Rights to surplus assets on dissolution', 118 | category: 'ownership', 119 | }, 120 | 'rights-to-profit-or-income': { 121 | description: 'Rights to receive profits or income', 122 | category: 'ownership', 123 | }, 124 | 'rights-granted-by-contract': { 125 | description: 'Rights granted by contract', 126 | category: 'control', 127 | }, 128 | 'conditional-rights-granted-by-contract': { 129 | description: 'Conditional rights granted by contract', 130 | category: 'control', 131 | }, 132 | 'control-via-company-rules-or-articles': { 133 | description: 'Control via company rules or articles', 134 | category: 'control', 135 | }, 136 | 'control-by-legal-framework': { 137 | description: 'Control by legal framework', 138 | category: 'control', 139 | }, 140 | 'board-member': { 141 | description: 'Board member', 142 | category: 'control', 143 | }, 144 | 'board-chair': { 145 | description: 'Board chair', 146 | category: 'control', 147 | }, 148 | 'unknown-interest': { 149 | description: 'Unknown interest', 150 | category: '', 151 | }, 152 | 'unpublished-interest': { 153 | description: 'Unpublished interest', 154 | category: '', 155 | }, 156 | 'enjoyment-and-use-of-assets': { 157 | description: 'Enjoyment and use of assets', 158 | category: 'ownership', 159 | }, 160 | 'right-to-profit-or-income-from-assets': { 161 | description: 'Right to profit or income from assets', 162 | category: 'ownership', 163 | }, 164 | 'influence-or-control': { 165 | description: 'Influence or control', 166 | category: 'control', 167 | }, 168 | 'settlor-of-trust': { 169 | description: 'Settlor of trust', 170 | category: 'control', 171 | }, 172 | 'trustee-of-trust': { 173 | description: 'Trustee of a trust', 174 | category: 'control', 175 | }, 176 | 'protector-of-trust': { 177 | description: 'Protector of a trust', 178 | category: 'control', 179 | }, 180 | 'beneficiary-of-trust': { 181 | description: 'Beneficiary of a trust', 182 | category: 'ownership', 183 | }, 184 | 'other-influence-or-control-of-trust': { 185 | description: 'Other influence or control of a trust', 186 | category: 'control', 187 | }, 188 | 'rights-to-surplus-assets': { 189 | description: 'Rights to surplus assets', 190 | category: 'ownership', 191 | }, 192 | }; 193 | -------------------------------------------------------------------------------- /src/images/bovs-arrangement.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 11 | 15 | 16 | 17 | 20 | 24 | 25 | 26 | 28 | 32 | 33 | -------------------------------------------------------------------------------- /src/images/bovs-entity-unknown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/bovs-listed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/images/bovs-organisation.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 14 | 15 | -------------------------------------------------------------------------------- /src/images/bovs-person-unknown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/bovs-person.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/bovs-state.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /src/images/bovs-statebody.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/images/bovs-unknown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { getNodes } from './model/nodes/nodes.js'; 2 | import { checkInterests, getEdges } from './model/edges/edges.js'; 3 | import { 4 | setupD3, 5 | defineArrowHeads, 6 | createOwnershipCurve, 7 | createControlCurve, 8 | createUnknownCurve, 9 | createControlText, 10 | createOwnText, 11 | createUnknownText, 12 | setNodeLabelBkg, 13 | injectSVGElements, 14 | setZoomTransform, 15 | removeMarkers, 16 | setDashedLine, 17 | createUnspecifiedNode, 18 | } from './render/renderD3'; 19 | import { setupGraph, setEdges, setNodes } from './render/renderGraph'; 20 | import { setupUI, renderMessage, renderProperties, renderDateSlider } from './render/renderUI'; 21 | import { getDates, filteredData } from './utils/bods.js'; 22 | 23 | import './style.css'; 24 | 25 | export const selectData = ({ 26 | data, 27 | selectedData, 28 | container, 29 | imagesPath, 30 | labelLimit = 8, 31 | rankDir = 'LR', 32 | viewProperties = true, 33 | useTippy = false, 34 | currentlySelectedDate = null, 35 | }) => { 36 | const config = { 37 | data, 38 | container, 39 | imagesPath, 40 | labelLimit, 41 | rankDir, 42 | viewProperties, 43 | useTippy, 44 | }; 45 | 46 | if (data) { 47 | const version = data[0]?.publicationDetails?.bodsVersion || '0.4'; 48 | 49 | // Detect dates in data; default to most recent 50 | const dates = getDates(data); 51 | let selectedDate = currentlySelectedDate ? currentlySelectedDate : dates[dates.length - 1]; 52 | 53 | // Update selected date according to slider position 54 | renderDateSlider(dates, version, currentlySelectedDate); 55 | const slider = document.querySelector('#slider-input'); 56 | if (slider) { 57 | slider.addEventListener('input', (e) => { 58 | const scrollPosition = window.scrollY; 59 | selectedDate = dates[document.querySelector('#slider-input').value]; 60 | config.selectedData = filteredData(data, selectedDate, version); 61 | draw(config); 62 | window.scrollTo(0, scrollPosition); 63 | slider.focus(); 64 | }); 65 | } 66 | config.selectedData = filteredData(data, selectedDate, version); 67 | draw(config); 68 | } 69 | }; 70 | 71 | // This sets up the basic format of the graph, such as direction, node and rank separation, and default label limits 72 | export const draw = (config) => { 73 | const { data, selectedData, container, imagesPath, labelLimit, rankDir, viewProperties, useTippy } = config; 74 | // Initialise D3 and graph 75 | const { svg, inner } = setupD3(container); 76 | const { g, render } = setupGraph(rankDir); 77 | 78 | defineArrowHeads(svg); 79 | 80 | // Extract the BODS data that is required for drawing the graph 81 | const { edges } = getEdges(selectedData); 82 | const { nodes } = getNodes(selectedData, edges); 83 | 84 | if (edges.length === 0 && nodes.length === 0) { 85 | const message = 'Your data does not have any information that can be drawn.'; 86 | renderMessage(message); 87 | } 88 | 89 | // This section maps the incoming BODS data to the parameters expected by Dagre 90 | setEdges(edges, g); 91 | setNodes(nodes, g); 92 | 93 | // Run the renderer. This is what draws the final graph. 94 | try { 95 | render(inner, g); 96 | } catch (error) { 97 | const message = 'Your data does not have any information that can be drawn.'; 98 | renderMessage(message); 99 | console.error(error); 100 | } 101 | 102 | // Inject SVG images (for use e.g. as flags) 103 | injectSVGElements(imagesPath, inner, g); 104 | 105 | // Create white backgrounds for all of the node labels so that text legible 106 | setNodeLabelBkg('white'); 107 | 108 | // stack unspecified nodes 109 | nodes.forEach((node) => { 110 | if (node?.class?.includes('unspecified')) { 111 | const element = g.node(node.id).elem; 112 | createUnspecifiedNode(element); 113 | } 114 | }); 115 | 116 | // calculate the new edges using control and ownership values 117 | // this section could do with a refactor and move more of the logic into edges.js 118 | edges.forEach((edge, index) => { 119 | const { 120 | source, 121 | target, 122 | interests, 123 | interestRelationship, 124 | shareStroke, 125 | shareText, 126 | controlStroke, 127 | controlText, 128 | unknownStroke, 129 | config, 130 | } = edge; 131 | 132 | const unknownOffset = unknownStroke / 2; 133 | const shareOffset = shareStroke / 2; 134 | const controlOffset = -(controlStroke / 2); 135 | const element = g.edge(source, target).elem; 136 | 137 | if (interests.some((item) => item.category === 'ownership')) { 138 | createOwnershipCurve( 139 | element, 140 | index, 141 | config.share.positiveStroke, 142 | config.share.strokeValue, 143 | shareOffset, 144 | checkInterests(interestRelationship), 145 | config.share.arrowheadShape 146 | ); 147 | } 148 | if (interests.some((item) => item.category === 'control')) { 149 | createControlCurve( 150 | element, 151 | index, 152 | config.control.positiveStroke, 153 | config.control.strokeValue, 154 | controlOffset, 155 | checkInterests(interestRelationship), 156 | config.control.arrowheadShape 157 | ); 158 | } 159 | if (!interests.length || interests.some((item) => item.category === '')) { 160 | createUnknownCurve( 161 | element, 162 | index, 163 | config.unknown.positiveStroke, 164 | config.unknown.strokeValue, 165 | unknownOffset, 166 | checkInterests(interestRelationship), 167 | config.unknown.arrowheadShape 168 | ); 169 | } 170 | 171 | // this creates the edge labels, and will allow the labels to be turned off if the node count exceeds the labelLimit 172 | const limitLabels = (createLabel) => g.nodeCount() < labelLimit && createLabel; 173 | 174 | limitLabels(createControlText(svg, index, controlText)); 175 | limitLabels(createOwnText(svg, index, shareText)); 176 | 177 | // This removes the markers from any edges that have either ownership or control 178 | if ( 179 | interests.some((item) => item.category === 'ownership') || 180 | interests.some((item) => item.category === 'control') 181 | ) { 182 | removeMarkers(g, source, target); 183 | } 184 | }); 185 | 186 | const { zoom } = setZoomTransform(inner, svg); 187 | 188 | setupUI(zoom, inner, svg); 189 | 190 | if (viewProperties) { 191 | renderProperties(inner, g, useTippy); 192 | } 193 | }; 194 | -------------------------------------------------------------------------------- /src/model/edges/edges.js: -------------------------------------------------------------------------------- 1 | import { compareVersions } from 'compare-versions'; 2 | import { monotoneX } from '../../utils/curve.js'; 3 | import interestTypesCodelist from '../../codelists/interestTypes.js'; 4 | 5 | // This sets the style and shape of the edges using D3 parameters 6 | const edgeConfig = { 7 | style: 'fill: none; stroke: #000; stroke-width: 5px;', 8 | curve: monotoneX, 9 | }; 10 | 11 | const defaultStroke = 5; 12 | 13 | const getInterests = (interests) => { 14 | return interests.reduce((acc, interest) => { 15 | const { type, share, endDate } = interest; 16 | const typeKey = type; 17 | const typeCategory = interestTypesCodelist[type]?.category || ''; 18 | 19 | const transformedInterest = { 20 | type: typeKey, 21 | share, 22 | category: typeCategory, 23 | }; 24 | 25 | acc.push(transformedInterest); 26 | return acc; 27 | }, []); 28 | }; 29 | 30 | const getStroke = (shareValues) => { 31 | const { exact, minimum, exclusiveMinimum, maximum, exclusiveMaximum } = shareValues?.share || {}; 32 | if (exact === undefined) { 33 | if ( 34 | (minimum !== undefined || exclusiveMinimum !== undefined) && 35 | (maximum !== undefined || exclusiveMaximum !== undefined) 36 | ) { 37 | return ((minimum || exclusiveMinimum) + (maximum || exclusiveMaximum)) / 2 / 10; 38 | } else { 39 | return defaultStroke; 40 | } 41 | } else { 42 | return exact / 10; 43 | } 44 | }; 45 | 46 | const getText = (shareValues, type) => { 47 | const { exact, minimum, exclusiveMinimum, maximum, exclusiveMaximum } = shareValues?.share || {}; 48 | if (exact === undefined) { 49 | if ( 50 | (minimum !== undefined || exclusiveMinimum !== undefined) && 51 | (maximum !== undefined || exclusiveMaximum !== undefined) 52 | ) { 53 | return `${type} ${minimum || exclusiveMinimum} - ${maximum || exclusiveMaximum}%`; 54 | } else { 55 | return `${type}`; 56 | } 57 | } else { 58 | return `${type} ${exact}%`; 59 | } 60 | }; 61 | 62 | export const checkInterests = (interestRelationship) => { 63 | return interestRelationship === 'indirect' || interestRelationship === '' ? true : false; 64 | }; 65 | 66 | export const getOwnershipEdges = (bodsData) => { 67 | const version = bodsData[0]?.publicationDetails?.bodsVersion || '0.4'; 68 | 69 | const filteredData = bodsData.filter((statement) => { 70 | if (compareVersions(version, '0.4') >= 0) { 71 | return statement.recordType === 'relationship'; 72 | } else { 73 | return statement.statementType === 'ownershipOrControlStatement'; 74 | } 75 | }); 76 | 77 | const mappedData = filteredData.map((statement) => { 78 | const { 79 | statementID = null, 80 | statementId = null, 81 | statementDate = null, 82 | recordId = null, 83 | recordStatus, 84 | recordDetails = null, 85 | subject, 86 | interestedParty, 87 | interests, 88 | } = statement; 89 | const replaces = statement.replacesStatements ? statement.replacesStatements : []; 90 | 91 | const interestsData = recordDetails?.interests || interests || []; 92 | const { interestLevel, directOrIndirect } = interestsData 93 | ? interestsData[0] || { interestLevel: 'unknown' } 94 | : { interestLevel: 'unknown' }; 95 | 96 | let interestRelationship, source, target; 97 | if (compareVersions(version, '0.4') >= 0) { 98 | if (directOrIndirect) { 99 | interestRelationship = directOrIndirect; 100 | } else { 101 | if (!interestsData.length) { 102 | interestRelationship = ''; 103 | } else { 104 | interestRelationship = 'unknown'; 105 | } 106 | } 107 | source = typeof recordDetails.interestedParty === 'string' ? recordDetails.interestedParty : 'unknown'; 108 | target = typeof recordDetails.subject === 'string' ? recordDetails.subject : 'unknown'; 109 | } else { 110 | if (directOrIndirect) { 111 | interestRelationship = directOrIndirect; 112 | } else if (interestLevel) { 113 | interestRelationship = interestLevel; 114 | } else { 115 | if (!interestsData.length) { 116 | interestRelationship = ''; 117 | } else { 118 | interestRelationship = 'unknown'; 119 | } 120 | } 121 | source = 122 | interestedParty?.describedByPersonStatement || 123 | interestedParty?.describedByEntityStatement || 124 | 'unknown'; 125 | target = subject.describedByPersonStatement 126 | ? subject.describedByPersonStatement 127 | : subject.describedByEntityStatement; 128 | } 129 | 130 | const mappedInterests = getInterests(interestsData); 131 | 132 | // work out the ownership stroke and text 133 | const unknownStroke = getStroke(mappedInterests.find((item) => item.category === '')); 134 | const shareStroke = getStroke(mappedInterests.find((item) => item.category === 'ownership')); 135 | const shareText = getText( 136 | mappedInterests.find((item) => item.category === 'ownership'), 137 | 'Owns' 138 | ); 139 | 140 | const controlStroke = getStroke(mappedInterests.find((item) => item.category === 'control')); 141 | const controlText = getText( 142 | mappedInterests.find((item) => item.category === 'control'), 143 | 'Controls' 144 | ); 145 | 146 | if (mappedInterests.some((item) => item.category === 'ownership')) { 147 | const arrowheadColour = shareStroke === 0 ? 'black' : ''; 148 | const arrowheadShape = `${arrowheadColour}${ 149 | mappedInterests.some((item) => item.category === 'control') ? 'Half' : 'Full' 150 | }`; 151 | const strokeValue = shareStroke === 0 ? '#000' : '#652eb1'; 152 | const positiveStroke = shareStroke === 0 ? 1 : shareStroke; 153 | 154 | edgeConfig.share = { 155 | arrowheadShape, 156 | strokeValue, 157 | positiveStroke, 158 | }; 159 | } 160 | if (mappedInterests.some((item) => item.category === 'control')) { 161 | const arrowheadColour = controlStroke === 0 ? 'black' : ''; 162 | const arrowheadShape = `${arrowheadColour}${ 163 | mappedInterests.some((item) => item.category === 'ownership') ? 'Half' : 'Full' 164 | }`; 165 | const strokeValue = controlStroke === 0 ? '#000' : '#349aee'; 166 | const positiveStroke = controlStroke === 0 ? 1 : controlStroke; 167 | 168 | edgeConfig.control = { 169 | arrowheadShape, 170 | strokeValue, 171 | positiveStroke, 172 | }; 173 | } 174 | if (!mappedInterests.length || mappedInterests.some((item) => item.category === '')) { 175 | edgeConfig.unknown = { 176 | arrowheadShape: 'blackFull', 177 | strokeValue: '#000', 178 | positiveStroke: unknownStroke, 179 | }; 180 | } 181 | 182 | return { 183 | id: statementId || statementID, 184 | statementDate, 185 | recordId, 186 | recordStatus, 187 | interests: mappedInterests, 188 | interestRelationship, 189 | controlStroke, 190 | controlText, 191 | shareText, 192 | shareStroke, 193 | unknownStroke, 194 | source, 195 | target, 196 | config: { ...edgeConfig }, 197 | replaces: replaces, 198 | fullDescription: statement, 199 | description: { 200 | statementDate, 201 | recordId, 202 | interests: recordDetails?.interests || interests || [], 203 | }, 204 | }; 205 | }); 206 | 207 | return mappedData; 208 | }; 209 | 210 | export const getEdges = (data) => { 211 | const ownershipEdges = getOwnershipEdges(data); 212 | return { edges: [...ownershipEdges] }; 213 | }; 214 | -------------------------------------------------------------------------------- /src/model/nodes/nodeSVGLabel.js: -------------------------------------------------------------------------------- 1 | const generateNodeLabel = (nodeText) => { 2 | const text_label = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 3 | const svg_label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); 4 | svg_label.setAttribute('x', 0); 5 | svg_label.setAttribute('y', 90); 6 | const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan'); 7 | tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve'); 8 | tspan.textContent = nodeText; 9 | svg_label.appendChild(tspan); 10 | text_label.appendChild(svg_label); 11 | 12 | return text_label; 13 | }; 14 | 15 | export default generateNodeLabel; 16 | -------------------------------------------------------------------------------- /src/model/nodes/nodes.js: -------------------------------------------------------------------------------- 1 | import { compareVersions } from 'compare-versions'; 2 | import generateNodeLabel from './nodeSVGLabel.js'; 3 | import sanitise from '../../utils/sanitiser.js'; 4 | 5 | // This will generate a node when there are unspecified fields 6 | const unknownNode = (nodeId) => { 7 | return { 8 | statementId: nodeId, 9 | statementID: nodeId, 10 | recordType: 'person', 11 | statementType: 'personStatement', 12 | personType: 'unknown', 13 | names: [{ fullName: 'Unknown' }], 14 | }; 15 | }; 16 | 17 | const unspecifiedNode = (nodeId) => { 18 | return { 19 | statementId: nodeId, 20 | statementID: nodeId, 21 | recordType: 'person', 22 | statementType: 'personStatement', 23 | personType: 'unspecified', 24 | names: [{ fullName: 'Unspecified' }], 25 | }; 26 | }; 27 | 28 | const nodeConfig = { 29 | shape: 'circle', 30 | width: 100, 31 | style: 'opacity: 1; fill: #fff; stroke: #000; stroke-width: 4px;', 32 | }; 33 | 34 | const unknownNodeConfig = { 35 | shape: 'circle', 36 | width: 100, 37 | style: 'opacity: 1; fill: #fff; stroke: #000; stroke-width: 4px; stroke-dasharray: 4,4;', 38 | }; 39 | 40 | // This sets up the order in which names should be selected from the data 41 | const personName = (names, personType) => { 42 | const personTypes = [ 43 | 'individual', 44 | 'legal', 45 | 'transliteration', 46 | 'alternative', 47 | 'birth', 48 | 'translation', 49 | 'former', 50 | ]; 51 | const selectedName = names 52 | .slice() 53 | .sort((a, b) => personTypes.indexOf(a.type) - personTypes.indexOf(b.type))[0]; 54 | 55 | // This creates the personType if the person is anonymous, unknown, or unanmed and describes the conditions for each 56 | if (Object.keys(selectedName).length === 0) { 57 | if (personType === 'anonymousPerson') { 58 | return 'Anonymous Person'; 59 | } else if (personType === 'unknownPerson') { 60 | return 'Unknown Person'; 61 | } else { 62 | return 'Unnamed Person'; 63 | } 64 | } 65 | if (selectedName.fullName) { 66 | return selectedName.fullName; 67 | } 68 | const nameParts = [selectedName.givenName, selectedName.patronymicName, selectedName.familyName].filter( 69 | (namePart) => namePart !== null 70 | ); 71 | if (nameParts.filter((name) => name).length > 0) { 72 | return nameParts.join(' '); 73 | } else return 'Unnamed Person'; 74 | }; 75 | 76 | // These are a direct mapping from the nodetype to the respresentative SVG element 77 | const iconType = (nodeType) => { 78 | const iconFile = { 79 | knownPerson: 'bovs-person.svg', 80 | anonymousPerson: 'bovs-person-unknown.svg', 81 | unknownPerson: 'bovs-person-unknown.svg', 82 | registeredEntity: 'bovs-organisation.svg', 83 | registeredEntityListed: 'bovs-listed.svg', 84 | legalEntity: 'bovs-organisation.svg', 85 | arrangement: 'bovs-arrangement.svg', 86 | anonymousEntity: 'bovs-entity-unknown.svg', 87 | unknownEntity: 'bovs-entity-unknown.svg', 88 | state: 'bovs-state.svg', 89 | stateBody: 'bovs-statebody.svg', 90 | }[nodeType]; 91 | 92 | return iconFile ? iconFile : 'bovs-unknown.svg'; 93 | }; 94 | 95 | // This builds up the required person object from the BODS data, using the functions above 96 | export const getPersonNodes = (bodsData) => { 97 | const version = bodsData[0]?.publicationDetails?.bodsVersion || '0.4'; 98 | 99 | const filteredData = bodsData.filter((statement) => { 100 | if (compareVersions(version, '0.4') >= 0) { 101 | return statement.recordType === 'person'; 102 | } else { 103 | return statement.statementType === 'personStatement'; 104 | } 105 | }); 106 | 107 | const mappedData = filteredData.map((statement) => { 108 | const { 109 | statementID = null, 110 | statementId = null, 111 | statementDate = null, 112 | recordId = null, 113 | recordDetails = {}, 114 | names = null, 115 | personType = '', 116 | nationalities = null, 117 | } = statement; 118 | const countryCode = nationalities && nationalities[0].code ? sanitise(nationalities[0].code) : null; 119 | const replaces = statement.replacesStatements ? statement.replacesStatements : []; 120 | 121 | const personNameData = recordDetails?.names || names; 122 | const personTypeData = recordDetails?.personType || personType || 'unknownPerson'; 123 | 124 | const personLabel = 125 | personNameData && personNameData.length > 0 && personTypeData 126 | ? generateNodeLabel(personName(personNameData, personTypeData)) 127 | : generateNodeLabel('Unknown Person'); 128 | return { 129 | id: statementId || statementID, 130 | statementDate, 131 | recordId, 132 | label: personLabel, 133 | labelType: 'svg', 134 | class: personType, 135 | config: personType !== 'unspecified' ? nodeConfig : unknownNodeConfig, 136 | replaces: replaces, 137 | nodeType: iconType(personTypeData), 138 | countryCode: countryCode, 139 | fullDescription: statement, 140 | description: { 141 | statementDate, 142 | recordId, 143 | identifiers: recordDetails?.identifiers || statement.identifiers || [], 144 | }, 145 | }; 146 | }); 147 | 148 | return mappedData; 149 | }; 150 | 151 | // This builds up the required entity object from the BODS data, using the functions above 152 | export const getEntityNodes = (bodsData) => { 153 | const version = bodsData[0]?.publicationDetails?.bodsVersion || '0.4'; 154 | 155 | const filteredData = bodsData.filter((statement) => { 156 | if (compareVersions(version, '0.4') >= 0) { 157 | return statement.recordType === 'entity'; 158 | } else { 159 | return statement.statementType === 'entityStatement'; 160 | } 161 | }); 162 | 163 | const mappedData = filteredData.map((statement) => { 164 | const { 165 | statementID = null, 166 | statementId = null, 167 | statementDate = null, 168 | recordId = null, 169 | recordDetails = {}, 170 | name = null, 171 | entityType = '', 172 | publicListing = null, 173 | incorporatedInJurisdiction = null, 174 | jurisdiction = null, 175 | } = statement; 176 | 177 | let countryCode; 178 | 179 | if (compareVersions(version, '0.4') >= 0) { 180 | countryCode = recordDetails.jurisdiction ? sanitise(recordDetails.jurisdiction.code) : null; 181 | } else { 182 | // This gets the country code from v0.2 BODS (incorporatedInJurisdiction) 183 | // Or from v0.3 BODS (jurisdiction) 184 | countryCode = incorporatedInJurisdiction 185 | ? sanitise(incorporatedInJurisdiction.code) 186 | : jurisdiction 187 | ? sanitise(jurisdiction.code) 188 | : null; 189 | } 190 | 191 | const replaces = statement.replacesStatements ? statement.replacesStatements : []; 192 | const nodeType = 193 | publicListing?.hasPublicListing === true || recordDetails?.publicListing?.hasPublicListing === true 194 | ? 'registeredEntityListed' 195 | : recordDetails?.entityType?.type || entityType || 'unknownEntity'; 196 | 197 | return { 198 | id: statementId || statementID, 199 | statementDate, 200 | recordId, 201 | label: generateNodeLabel(recordDetails?.name || name || ''), 202 | labelType: 'svg', 203 | class: entityType, 204 | nodeType: iconType(nodeType), 205 | countryCode: countryCode, 206 | config: nodeConfig, 207 | replaces: replaces, 208 | fullDescription: statement, 209 | description: { 210 | statementDate, 211 | recordId, 212 | identifiers: recordDetails?.identifiers || statement.identifiers || [], 213 | }, 214 | }; 215 | }); 216 | 217 | return mappedData; 218 | }; 219 | 220 | export const setUnknownNode = (source) => unknownNode(source); 221 | export const setUnspecifiedNode = (source) => unspecifiedNode(source); 222 | 223 | export const findMatchingStatement = (data, matchingId) => { 224 | let matchingStatement; 225 | const version = data[0]?.publicationDetails?.bodsVersion || '0.4'; 226 | 227 | if (compareVersions(version, '0.4') >= 0) { 228 | matchingStatement = data.find((statement) => statement.recordId === matchingId); 229 | } else { 230 | matchingStatement = data.find((statement) => statement.statementID === matchingId); 231 | } 232 | return matchingStatement; 233 | }; 234 | 235 | export const getNodes = (data, edges) => { 236 | const personNodes = getPersonNodes(data); 237 | const entityNodes = getEntityNodes(data); 238 | 239 | // Some of the edges have unspecified sources or targets so we map these to an inserted unknown node 240 | const unknownSubjects = edges.filter((edge, index) => { 241 | let source = edge.source; 242 | const match = findMatchingStatement(data, source); 243 | if (edge.source === 'unknown' || !match) { 244 | source = `unknownSubject${index}`; 245 | } 246 | return source === `unknownSubject${index}`; 247 | }); 248 | const unknownTargets = edges.filter((edge, index) => { 249 | let target = edge.target; 250 | const match = findMatchingStatement(data, target); 251 | if (edge.target === 'unknown' || !match) { 252 | target = `unknownTarget${index}`; 253 | } 254 | return target === `unknownTarget${index}`; 255 | }); 256 | const unspecifiedSubjects = edges.filter((edge, index) => { 257 | let source = edge.source; 258 | if (edge.source === 'unspecified') { 259 | source = `unspecifiedSubject${index}`; 260 | } 261 | return source === `unspecifiedSubject${index}`; 262 | }); 263 | const unspecifiedTargets = edges.filter((edge, index) => { 264 | let target = edge.target; 265 | if (edge.target === 'unspecified') { 266 | target = `unspecifiedTarget${index}`; 267 | } 268 | return target === `unspecifiedTarget${index}`; 269 | }); 270 | 271 | const unknownSubjectNodes = unknownSubjects.map((unknownSubject) => { 272 | return setUnknownNode(unknownSubject.source); 273 | }); 274 | const unknownTargetNodes = unknownTargets.map((unknownTarget) => { 275 | return setUnknownNode(unknownTarget.target); 276 | }); 277 | const unspecifiedSubjectNodes = unspecifiedSubjects.map((unspecifiedSubject) => { 278 | return setUnspecifiedNode(unspecifiedSubject.source); 279 | }); 280 | const unspecifiedTargetNodes = unspecifiedTargets.map((unspecifiedTarget) => { 281 | return setUnspecifiedNode(unspecifiedTarget.target); 282 | }); 283 | 284 | return { 285 | nodes: [ 286 | ...personNodes, 287 | ...entityNodes, 288 | ...getPersonNodes(unknownSubjectNodes), 289 | ...getPersonNodes(unknownTargetNodes), 290 | ...getPersonNodes(unspecifiedSubjectNodes), 291 | ...getPersonNodes(unspecifiedTargetNodes), 292 | ], 293 | }; 294 | }; 295 | -------------------------------------------------------------------------------- /src/parse/parse.js: -------------------------------------------------------------------------------- 1 | import { renderMessage } from '../render/renderUI.js'; 2 | 3 | export const parse = (data) => { 4 | let parsed; 5 | 6 | // Check data is valid JSON 7 | try { 8 | parsed = JSON.parse(data); 9 | } catch (error) { 10 | const message = 'There was an error drawing your data. The data must be a valid JSON array of objects.'; 11 | renderMessage(message); 12 | console.error(error); 13 | return []; 14 | } 15 | 16 | // Format JSON consistently 17 | const formatted = JSON.stringify(parsed, null, 2); 18 | 19 | // Return parsed and formatted JSON 20 | return { 21 | formatted, 22 | parsed: JSON.parse(formatted), 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/render/renderD3.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3'; 2 | import Bezier from 'bezier-js'; 3 | import SVGInjectInstance from '@iconfu/svg-inject'; 4 | 5 | import bezierBuilder from '../utils/bezierBuilder.js'; 6 | import { clearSVG } from '../utils/svgTools.js'; 7 | 8 | export const setupD3 = (container) => { 9 | // This ensures that the graph is drawn on a clean slate 10 | clearSVG(container); 11 | 12 | const svg = d3 13 | .select('#bods-svg') 14 | .attr('width', container.clientWidth) 15 | .attr('height', container.clientWidth); 16 | const inner = svg.append('g'); 17 | 18 | return { 19 | svg, 20 | inner, 21 | }; 22 | }; 23 | 24 | export const defineArrowHeads = (svg) => { 25 | // Set up a load of arrow heads and markers for bespoke edge formats 26 | const half = svg 27 | .append('defs') 28 | .append('marker') 29 | .attr('id', 'arrow-control-Half') 30 | .attr('viewBox', [0, 0, 10, 10]) 31 | .attr('refX', 8) 32 | .attr('refY', 3.8) 33 | .attr('markerUnits', 'userSpaceOnUse') 34 | .attr('markerWidth', 40) 35 | .attr('markerHeight', 40) 36 | .attr('orient', 'auto-start-reverse') 37 | .append('path') 38 | .attr('d', 'M 0 0 L 10 5 L 0 5 z') 39 | .attr('stroke', 'none') 40 | .attr('fill', '#349aee'); 41 | 42 | const full = svg 43 | .append('defs') 44 | .append('marker') 45 | .attr('id', 'arrow-control-Full') 46 | .attr('viewBox', [0, 0, 10, 10]) 47 | .attr('refX', 8) 48 | .attr('refY', 5) 49 | .attr('markerUnits', 'userSpaceOnUse') 50 | .attr('markerWidth', 40) 51 | .attr('markerHeight', 40) 52 | .attr('orient', 'auto-start-reverse') 53 | .append('path') 54 | .attr('d', 'M 0 0 L 10 5 L 0 10 z') 55 | .attr('stroke', 'none') 56 | .attr('fill', '#349aee'); 57 | 58 | const blackHalf = svg 59 | .append('defs') 60 | .append('marker') 61 | .attr('id', 'arrow-control-blackHalf') 62 | .attr('viewBox', [0, 0, 10, 10]) 63 | .attr('refX', 8) 64 | .attr('refY', 5) 65 | .attr('markerUnits', 'userSpaceOnUse') 66 | .attr('markerWidth', 40) 67 | .attr('markerHeight', 40) 68 | .attr('orient', 'auto-start-reverse') 69 | .append('path') 70 | .attr('d', 'M 0 0 L 10 5 L 0 10 z') 71 | .attr('stroke', 'none') 72 | .attr('fill', '#000'); 73 | 74 | const blackFull = svg 75 | .append('defs') 76 | .append('marker') 77 | .attr('id', 'arrow-control-blackFull') 78 | .attr('viewBox', [0, 0, 10, 10]) 79 | .attr('refX', 8) 80 | .attr('refY', 5) 81 | .attr('markerUnits', 'userSpaceOnUse') 82 | .attr('markerWidth', 40) 83 | .attr('markerHeight', 40) 84 | .attr('orient', 'auto-start-reverse') 85 | .append('path') 86 | .attr('d', 'M 0 0 L 10 5 L 0 10 z') 87 | .attr('stroke', 'none') 88 | .attr('fill', '#000'); 89 | 90 | const ownHalf = svg 91 | .append('defs') 92 | .append('marker') 93 | .attr('id', 'arrow-own-Half') 94 | .attr('viewBox', [0, 0, 10, 10]) 95 | .attr('refX', 8) 96 | .attr('refY', 6.1) 97 | .attr('markerUnits', 'userSpaceOnUse') 98 | .attr('markerWidth', 40) 99 | .attr('markerHeight', 40) 100 | .attr('orient', 'auto-start-reverse') 101 | .append('path') 102 | .attr('d', 'M 0 10 L 10 5 L 0 5 z') 103 | .attr('stroke', 'none') 104 | .attr('fill', '#652eb1'); 105 | 106 | const ownFull = svg 107 | .append('defs') 108 | .append('marker') 109 | .attr('id', 'arrow-own-Full') 110 | .attr('viewBox', [0, 0, 10, 10]) 111 | .attr('refX', 8) 112 | .attr('refY', 5) 113 | .attr('markerUnits', 'userSpaceOnUse') 114 | .attr('markerWidth', 40) 115 | .attr('markerHeight', 40) 116 | .attr('orient', 'auto-start-reverse') 117 | .append('path') 118 | .attr('d', 'M 0 10 L 10 5 L 0 0 z') 119 | .attr('stroke', 'none') 120 | .attr('fill', '#652eb1'); 121 | 122 | const ownBlackHalf = svg 123 | .append('defs') 124 | .append('marker') 125 | .attr('id', 'arrow-own-blackHalf') 126 | .attr('viewBox', [0, 0, 10, 10]) 127 | .attr('refX', 8) 128 | .attr('refY', 6.1) 129 | .attr('markerUnits', 'userSpaceOnUse') 130 | .attr('markerWidth', 40) 131 | .attr('markerHeight', 40) 132 | .attr('orient', 'auto-start-reverse') 133 | .append('path') 134 | .attr('d', 'M 0 10 L 10 5 L 0 5 z') 135 | .attr('stroke', 'none') 136 | .attr('fill', '#000'); 137 | 138 | const ownBlackFull = svg 139 | .append('defs') 140 | .append('marker') 141 | .attr('id', 'arrow-own-blackFull') 142 | .attr('viewBox', [0, 0, 10, 10]) 143 | .attr('refX', 8) 144 | .attr('refY', 5) 145 | .attr('markerUnits', 'userSpaceOnUse') 146 | .attr('markerWidth', 40) 147 | .attr('markerHeight', 40) 148 | .attr('orient', 'auto-start-reverse') 149 | .append('path') 150 | .attr('d', 'M 0 10 L 10 5 L 0 0 z') 151 | .attr('stroke', 'none') 152 | .attr('fill', '#000'); 153 | 154 | const unknownBlackHalf = svg 155 | .append('defs') 156 | .append('marker') 157 | .attr('id', 'arrow-unknown-blackHalf') 158 | .attr('viewBox', [0, 0, 10, 10]) 159 | .attr('refX', 8) 160 | .attr('refY', 6.1) 161 | .attr('markerUnits', 'userSpaceOnUse') 162 | .attr('markerWidth', 40) 163 | .attr('markerHeight', 40) 164 | .attr('orient', 'auto-start-reverse') 165 | .append('path') 166 | .attr('d', 'M 0 10 L 10 5 L 0 5 z') 167 | .attr('stroke', 'none') 168 | .attr('fill', '#000'); 169 | 170 | const unknownBlackFull = svg 171 | .append('defs') 172 | .append('marker') 173 | .attr('id', 'arrow-unknown-blackFull') 174 | .attr('viewBox', [0, 0, 10, 10]) 175 | .attr('refX', 8) 176 | .attr('refY', 5) 177 | .attr('markerUnits', 'userSpaceOnUse') 178 | .attr('markerWidth', 40) 179 | .attr('markerHeight', 40) 180 | .attr('orient', 'auto-start-reverse') 181 | .append('path') 182 | .attr('d', 'M 0 10 L 10 5 L 0 0 z') 183 | .attr('stroke', 'none') 184 | .attr('fill', '#000'); 185 | 186 | return { 187 | half, 188 | full, 189 | ownHalf, 190 | ownFull, 191 | blackHalf, 192 | blackFull, 193 | ownBlackHalf, 194 | ownBlackFull, 195 | unknownBlackHalf, 196 | unknownBlackFull, 197 | }; 198 | }; 199 | 200 | export const createUnspecifiedNode = (element) => { 201 | const translateExistingTransform = (d, i, nodes, x) => { 202 | // Get the current transform value (if any) 203 | const currentTransform = d3.select(nodes[i]).attr('transform') || 'translate(0,0)'; 204 | 205 | // Extract the current x and y translation values 206 | const translate = currentTransform.match(/translate\(([^,]+),([^\)]+)\)/); 207 | 208 | let currentX = 0; 209 | let currentY = 0; 210 | if (translate) { 211 | currentX = parseFloat(translate[1]); 212 | currentY = parseFloat(translate[2]); 213 | } 214 | 215 | return `translate(${currentX + x}, ${currentY})`; 216 | }; 217 | 218 | const firstClone = d3.select(element).clone(true); 219 | firstClone 220 | .attr('transform', (d, i, nodes) => translateExistingTransform(d, i, nodes, 10)) 221 | .lower() 222 | .selectAll('.label') 223 | .remove(); 224 | 225 | firstClone.select('circle').attr('style', 'fill:#fff;stroke:#888;stroke-width:4px;stroke-dasharray:4,4;'); 226 | 227 | const secondClone = d3.select(element).clone(true); 228 | secondClone 229 | .attr('transform', (d, i, nodes) => translateExistingTransform(d, i, nodes, 20)) 230 | .lower() 231 | .selectAll('.label') 232 | .remove(); 233 | 234 | secondClone.select('circle').attr('style', 'fill:#fff;stroke:#888;stroke-width:4px;stroke-dasharray:4,4;'); 235 | }; 236 | 237 | // define the additional offset curves and text for ownership and control edges 238 | export const createOwnershipCurve = ( 239 | element, 240 | index, 241 | positiveStroke, 242 | strokeValue, 243 | curveOffset, 244 | dashedInterest, 245 | arrowheadShape 246 | ) => { 247 | d3.select(element) 248 | .attr('style', 'opacity: 0;') 249 | .clone(true) 250 | .attr('style', 'opacity: 1;') 251 | .attr('class', 'edgePath own') 252 | .select('path') 253 | .attr('id', (d, i) => 'ownPath' + index) 254 | .attr('marker-end', `url(#arrow-own-${arrowheadShape})`) 255 | .attr( 256 | 'style', 257 | `fill: none; stroke: ${strokeValue}; stroke-width: ${positiveStroke}px; ${ 258 | dashedInterest ? 'stroke-dasharray: 20,12' : '' 259 | };` 260 | ) 261 | .each(function () { 262 | const path = d3.select(this); 263 | const newBezier = Bezier.SVGtoBeziers(path.attr('d')); 264 | const offsetCurve = newBezier.offset(curveOffset); 265 | path.attr('d', bezierBuilder(offsetCurve)); 266 | }); 267 | 268 | d3.select(element) 269 | .clone(true) 270 | .select('.path') 271 | .attr('id', (d, i) => 'ownText' + index) 272 | .attr('style', 'fill: none;') 273 | .attr('marker-end', '') 274 | .each(function () { 275 | const path = d3.select(this); 276 | const newBezier = Bezier.SVGtoBeziers(path.attr('d')); 277 | const offsetCurve = newBezier.offset(25); 278 | path.attr('d', bezierBuilder(offsetCurve)); 279 | }); 280 | }; 281 | 282 | export const createControlCurve = ( 283 | element, 284 | index, 285 | positiveStroke, 286 | strokeValue, 287 | curveOffset, 288 | dashedInterest, 289 | arrowheadShape 290 | ) => { 291 | d3.select(element) 292 | .attr('style', 'opacity: 0;') 293 | .clone(true) 294 | .attr('style', 'opacity: 1;') 295 | .attr('class', 'edgePath control') 296 | .select('.path') 297 | .attr('id', (d, i) => 'controlPath' + index) 298 | .attr('marker-end', `url(#arrow-control-${arrowheadShape})`) 299 | .attr( 300 | 'style', 301 | `fill: none; stroke: ${strokeValue}; stroke-width: ${positiveStroke}px; ${ 302 | dashedInterest ? 'stroke-dasharray: 20,12' : '' 303 | };` 304 | ) 305 | .each(function () { 306 | const path = d3.select(this); 307 | const newBezier = Bezier.SVGtoBeziers(path.attr('d')); 308 | const offsetCurve = newBezier.offset(curveOffset); 309 | path.attr('d', bezierBuilder(offsetCurve)); 310 | }); 311 | 312 | d3.select(element) 313 | .clone(true) 314 | .select('.path') 315 | .attr('id', (d, i) => 'controlText' + index) 316 | .attr('style', 'fill: none;') 317 | .attr('marker-end', '') 318 | .each(function () { 319 | const path = d3.select(this); 320 | const newBezier = Bezier.SVGtoBeziers(path.attr('d')); 321 | const offsetCurve = newBezier.offset(-15); 322 | path.attr('d', bezierBuilder(offsetCurve)); 323 | }); 324 | }; 325 | 326 | export const createUnknownCurve = ( 327 | element, 328 | index, 329 | positiveStroke, 330 | strokeValue, 331 | curveOffset, 332 | dashedInterest, 333 | arrowheadShape 334 | ) => { 335 | d3.select(element) 336 | .attr('style', 'opacity: 0;') 337 | .clone(true) 338 | .attr('style', 'opacity: 1;') 339 | .attr('class', 'edgePath control') 340 | .select('.path') 341 | .attr('id', (d, i) => 'unknownPath' + index) 342 | .attr('marker-end', `url(#arrow-unknown-${arrowheadShape})`) 343 | .attr( 344 | 'style', 345 | `fill: none; stroke: ${strokeValue}; stroke-width: ${positiveStroke}px; ${ 346 | dashedInterest ? 'stroke-dasharray: 20,12' : '' 347 | };` 348 | ) 349 | .each(function () { 350 | const path = d3.select(this); 351 | const newBezier = Bezier.SVGtoBeziers(path.attr('d')); 352 | const offsetCurve = newBezier.offset(curveOffset); 353 | path.attr('d', bezierBuilder(offsetCurve)); 354 | }); 355 | 356 | d3.select(element) 357 | .clone(true) 358 | .select('.path') 359 | .attr('id', (d, i) => 'unknownText' + index) 360 | .attr('style', 'fill: none;') 361 | .attr('marker-end', '') 362 | .each(function () { 363 | const path = d3.select(this); 364 | const newBezier = Bezier.SVGtoBeziers(path.attr('d')); 365 | const offsetCurve = newBezier.offset(-15); 366 | path.attr('d', bezierBuilder(offsetCurve)); 367 | }); 368 | }; 369 | 370 | export const createControlText = (svg, index, controlText) => { 371 | svg 372 | .select('.edgeLabels') 373 | .append('g') 374 | .attr('class', 'edgeLabel') 375 | .append('text') 376 | .attr('class', 'edgeText') 377 | .attr('text-anchor', 'middle') 378 | .append('textPath') 379 | .attr('xlink:href', function (d, i) { 380 | return '#controlText' + index; 381 | }) 382 | .attr('startOffset', '50%') 383 | .text(controlText) 384 | .style('fill', '#349aee'); 385 | }; 386 | 387 | export const createOwnText = (svg, index, shareText) => { 388 | svg 389 | .select('.edgeLabels') 390 | .append('g') 391 | .attr('class', 'edgeLabel') 392 | .append('text') 393 | .attr('class', 'edgeText') 394 | .attr('text-anchor', 'middle') 395 | .append('textPath') 396 | .attr('xlink:href', function (d, i) { 397 | return '#ownText' + index; 398 | }) 399 | .attr('startOffset', '50%') 400 | .text(shareText) 401 | .style('fill', '#652eb1'); 402 | }; 403 | 404 | export const setNodeLabelBkg = (color) => { 405 | d3.selectAll('.edgeLabels .edgeLabel .label, .nodes .node .label').each(function (d, i) { 406 | const label = d3.select(this); 407 | const text = label.select('text'); 408 | const textParent = text.select(function () { 409 | return this.parentNode; 410 | }); 411 | const bBox = text.node().getBBox(); 412 | 413 | textParent 414 | .insert('rect', ':first-child') 415 | .attr('x', bBox.x) 416 | .attr('y', bBox.y) 417 | .attr('height', bBox.height) 418 | .attr('width', bBox.width) 419 | .style('fill', color); 420 | }); 421 | }; 422 | 423 | export const injectSVGElements = (imagesPath, inner, g) => { 424 | // create the nodetype injectable img elements used by an image injection library later 425 | inner.selectAll('g.node').each(function (d, i) { 426 | if (g.node(d).nodeType !== null) { 427 | d3.select(this) 428 | .append('img') 429 | .attr('width', 120) 430 | .attr('height', 120) 431 | .attr('x', -60) 432 | .attr('y', -60) 433 | .attr('class', 'node-label label-container injectable') 434 | .attr('src', function (d) { 435 | return `${imagesPath}/${g.node(d).nodeType}`; 436 | }); 437 | } 438 | }); 439 | 440 | // create the flag injectable img elements 441 | inner.selectAll('g.node').each(function (d, i) { 442 | if (g.node(d).countryCode !== null) { 443 | d3.select(this) 444 | .append('img') 445 | .attr('src', function (el) { 446 | return `${imagesPath}/flags/${g.node(el).countryCode.toLowerCase()}.svg`; 447 | }) 448 | .attr('width', '75') 449 | .attr('height', '50') 450 | .attr('x', '10') 451 | .attr('y', '-80') 452 | .attr('class', 'injectable flag'); 453 | } 454 | }); 455 | 456 | if (document.querySelector('img.injectable')) { 457 | // this allows SVGs to be injected directly from source 458 | SVGInjectInstance(document.querySelectorAll('img.injectable')).then(() => { 459 | d3.selectAll('.flag') 460 | .insert('rect', ':first-child') 461 | .attr('class', 'flag-bg') 462 | .attr('height', '100%') 463 | .attr('width', '100%') 464 | .attr('style', 'fill: white; stroke: black; stroke-width: 2px;'); 465 | }); 466 | } 467 | }; 468 | 469 | export const setZoomTransform = (inner, svg) => { 470 | // Set up zoom support 471 | const zoom = d3.zoom().on('zoom', (zoomEvent) => { 472 | inner.attr('transform', zoomEvent.transform); 473 | }); 474 | svg.call(zoom); 475 | 476 | const bounds = inner.node().getBBox(); 477 | const width = svg.attr('width'); 478 | const height = svg.attr('height'); 479 | 480 | // Offset zoom level to account for flags and apply small border of whitespace 481 | const zoomOffset = 50; 482 | 483 | // Scale and center the graph 484 | svg.call( 485 | zoom.transform, 486 | d3.zoomIdentity 487 | .translate(width / 2, height / 2) 488 | .scale(Math.min(width / (bounds.width + zoomOffset), height / (bounds.height + zoomOffset))) 489 | .translate(-bounds.x - bounds.width / 2, -bounds.y - bounds.height / 2) 490 | ); 491 | 492 | return { zoom }; 493 | }; 494 | 495 | export const removeMarkers = (g, source, target) => { 496 | d3.select(g.edge(source, target).elem).select('path').attr('marker-end', ''); 497 | }; 498 | 499 | export const setDashedLine = (element) => { 500 | d3.select(element).style('stroke-dasharray: 20,12'); 501 | }; 502 | -------------------------------------------------------------------------------- /src/render/renderGraph.js: -------------------------------------------------------------------------------- 1 | import * as dagreD3 from 'dagre-d3-es'; 2 | 3 | export const setupGraph = (rankDir) => { 4 | const g = new dagreD3.graphlib.Graph({}); 5 | g.setGraph({ 6 | rankdir: rankDir, 7 | nodesep: 200, 8 | edgesep: 25, 9 | ranksep: 300, 10 | }); 11 | 12 | // Create the renderer 13 | const render = new dagreD3.render(); 14 | 15 | return { 16 | g, 17 | render, 18 | }; 19 | }; 20 | 21 | export const setNodes = (nodes, g) => { 22 | nodes.forEach((node) => { 23 | g.setNode(node.recordId || node.id, { 24 | id: `node_${node.recordId || node.id}`, 25 | label: node.label, 26 | class: node.class || '', 27 | labelType: node.labelType || 'string', 28 | nodeType: node.nodeType, 29 | countryCode: node.countryCode, 30 | description: node.description, 31 | fullDescription: node.fullDescription, 32 | ...node.config, 33 | }); 34 | }); 35 | }; 36 | 37 | export const setEdges = (edges, g) => { 38 | edges.forEach((edge) => { 39 | g.setEdge(edge.source, edge.target, { 40 | id: `edge_${edge.recordId || edge.id}`, 41 | class: edge.class || '', 42 | edgeType: edge.interestRelationship, 43 | description: edge.description, 44 | fullDescription: edge.fullDescription, 45 | ...edge.config, 46 | }); 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /src/render/renderUI.js: -------------------------------------------------------------------------------- 1 | import { compareVersions } from 'compare-versions'; 2 | import tippy, { hideAll } from 'tippy.js'; 3 | import 'tippy.js/dist/tippy.css'; 4 | import 'tippy.js/themes/light-border.css'; 5 | import { setZoomTransform } from './renderD3'; 6 | import { SvgSaver } from '../utils/svgsaver'; 7 | 8 | export const setupUI = (zoom, inner, svg) => { 9 | const zoomInBtn = document.querySelector('#zoom_in'); 10 | const zoomOutBtn = document.querySelector('#zoom_out'); 11 | const downloadSVGBtn = document.querySelector('#download-svg'); 12 | const downloadPNGBtn = document.querySelector('#download-png'); 13 | const svgElement = document.querySelector('#bods-svg'); 14 | 15 | const svgsaver = new SvgSaver(); 16 | 17 | zoomInBtn.addEventListener('click', () => { 18 | zoom.scaleBy(svg.transition().duration(750), 1.2); 19 | }); 20 | zoomInBtn.addEventListener('keyup', (e) => { 21 | if (e.key === 'Enter' || e.key === 'Space') { 22 | zoom.scaleBy(svg.transition().duration(750), 1.2); 23 | } 24 | }); 25 | 26 | zoomOutBtn.addEventListener('click', () => { 27 | zoom.scaleBy(svg.transition().duration(750), 0.8); 28 | }); 29 | zoomOutBtn.addEventListener('keyup', (e) => { 30 | if (e.key === 'Enter' || e.key === 'Space') { 31 | zoom.scaleBy(svg.transition().duration(750), 0.8); 32 | } 33 | }); 34 | 35 | downloadSVGBtn.addEventListener('click', (e) => { 36 | e.stopPropagation(); 37 | setZoomTransform(inner, svg); 38 | svgsaver.asSvg(svgElement, 'bods.svg'); 39 | }); 40 | downloadSVGBtn.addEventListener('keyup', (e) => { 41 | e.stopPropagation(); 42 | if (e.key === 'Enter' || e.key === 'Space') { 43 | setZoomTransform(inner, svg); 44 | svgsaver.asSvg(svgElement, 'bods.svg'); 45 | } 46 | }); 47 | 48 | downloadPNGBtn.addEventListener('click', (e) => { 49 | e.stopPropagation(); 50 | setZoomTransform(inner, svg); 51 | svgsaver.asPng(svgElement, 'bods.png'); 52 | }); 53 | downloadPNGBtn.addEventListener('keyup', (e) => { 54 | e.stopPropagation(); 55 | if (e.key === 'Enter' || e.key === 'Space') { 56 | setZoomTransform(inner, svg); 57 | svgsaver.asPng(svgElement, 'bods.png'); 58 | } 59 | }); 60 | }; 61 | 62 | const getDescription = (description) => { 63 | // Extract identifiers from description and output text on newlines 64 | let identifiers = []; 65 | let identifiersOutput = ''; 66 | if (description.identifiers) { 67 | identifiers = description.identifiers.map((identifier, index) => ({ 68 | [`Identifier ${index + 1}`]: `${identifier.scheme ? '(' + identifier.scheme + ') ' : ''}${ 69 | identifier.id 70 | }`, 71 | })); 72 | identifiers.forEach((item) => { 73 | const key = Object.keys(item)[0]; 74 | const value = item[key]; 75 | identifiersOutput += `${key}: ${value}\n`; 76 | }); 77 | } 78 | 79 | // Extract interests from description and output text on newlines 80 | let interests = []; 81 | let interestsOutput = ''; 82 | if (description.interests) { 83 | interests = description.interests.map((interest, index) => { 84 | if (interest.startDate) { 85 | return { 86 | [`Interest ${index + 1} Type`]: `${interest.type}\n`, 87 | [`Interest ${index + 1} Start Date`]: `${interest.startDate}\n`, 88 | }; 89 | } else { 90 | return { 91 | [`Interest ${index + 1} Type`]: `${interest.type}\n`, 92 | }; 93 | } 94 | }); 95 | interests.forEach((item) => { 96 | for (const key in item) { 97 | if (item.hasOwnProperty(key)) { 98 | interestsOutput += `${key}: ${item[key]}`; 99 | } 100 | } 101 | }); 102 | } 103 | 104 | // Output the descriptions subset as key value pairs on new lines 105 | return `Statement date: ${description.statementDate}\n${ 106 | description.recordId !== null ? 'Record ID: ' + description.recordId + '\n' : '' 107 | }${identifiers.length > 0 ? identifiersOutput : ''}${interests.length > 0 ? interestsOutput : ''}`; 108 | }; 109 | 110 | // Configure tippy.js 111 | const setTippyInstance = (element, content) => { 112 | return tippy(element, { 113 | content: `
${content}
`, 114 | allowHTML: true, 115 | trigger: 'manual', 116 | hideOnClick: true, 117 | interactive: true, 118 | theme: 'light-border', 119 | appendTo: document.body, 120 | }); 121 | }; 122 | 123 | // Generic function to check if elements (multiple) exist in the DOM 124 | function waitForElementsToExist(selector, callback) { 125 | if (document.querySelectorAll(selector)) { 126 | return callback(document.querySelectorAll(selector)); 127 | } 128 | 129 | const intervalId = setInterval(() => { 130 | if (document.querySelectorAll(selector)) { 131 | clearInterval(intervalId); 132 | callback(document.querySelectorAll(selector)); 133 | } 134 | }, 500); 135 | } 136 | 137 | export const renderMessage = (message) => { 138 | alert(message); 139 | }; 140 | 141 | export const renderProperties = (inner, g, useTippy) => { 142 | // Only use tippy.js if the useTippy property is true 143 | if (useTippy) { 144 | // Pre-emptively hide any rogue open tooltips 145 | hideAll({ duration: 0 }); 146 | } 147 | const disclosureWidget = document.querySelector('#disclosure-widget'); 148 | disclosureWidget.innerHTML = ''; 149 | 150 | const nodes = inner.selectAll('g.node'); 151 | nodes.each((d, i) => { 152 | const node = g.node(d); 153 | // Don't display statement properties if the node is unspecified 154 | if (node?.class?.includes('unknown') || node?.class?.includes('unspecified')) { 155 | return; 156 | } 157 | const description = getDescription(node.description); 158 | const fullDescription = JSON.stringify(node.fullDescription, null, 2); 159 | 160 | node.elem.addEventListener('click', () => { 161 | // Populate disclosure widget with node `fullDescription` JSON 162 | disclosureWidget.innerHTML = `
Properties
${fullDescription}
`; 163 | 164 | // Only use tippy.js if the useTippy property is true 165 | if (useTippy) { 166 | // Pre-emptively hide any rogue open tooltips 167 | hideAll(); 168 | 169 | // Create tooltip instance and display it 170 | const tippyInstance = setTippyInstance(node.elem, description); 171 | tippyInstance.show(); 172 | 173 | // Wait until the tooltip button exists before attaching a close event 174 | waitForElementsToExist('.close-tooltip', (elements) => { 175 | elements.forEach((element) => { 176 | element.addEventListener('click', () => { 177 | hideAll({ duration: 0 }); 178 | }); 179 | }); 180 | }); 181 | } 182 | }); 183 | }); 184 | 185 | const edges = inner.selectAll('g.edgePath'); 186 | edges.each((d, i) => { 187 | const edge = g.edge(d.v, d.w); 188 | const edgeInstances = document.querySelectorAll(`#${edge.elem.id}`); 189 | const description = getDescription(edge.description); 190 | const fullDescription = JSON.stringify(edge.fullDescription, null, 2); 191 | 192 | edgeInstances.forEach((edgeInstance) => { 193 | edgeInstance.addEventListener('click', () => { 194 | // Populate disclosure widget with edge `fullDescription` JSON 195 | disclosureWidget.innerHTML = `
Properties
${fullDescription}
`; 196 | 197 | // Only use tippy.js if the useTippy property is true 198 | if (useTippy) { 199 | // Pre-emptively hide any rogue open tooltips 200 | hideAll(); 201 | 202 | // Create tooltip instance and display it 203 | const tippyInstance = setTippyInstance(edgeInstance, description); 204 | tippyInstance.show(); 205 | 206 | // Wait until the tooltip button exists before attaching a close event 207 | waitForElementsToExist('.close-tooltip', (elements) => { 208 | elements.forEach((element) => { 209 | element.addEventListener('click', () => { 210 | hideAll(); 211 | }); 212 | }); 213 | }); 214 | } 215 | }); 216 | }); 217 | }); 218 | }; 219 | 220 | export const renderDateSlider = (dates, version, currentlySelectedDate) => { 221 | const sliderContainer = document.querySelector('#slider-container'); 222 | let selectedDate = currentlySelectedDate ? currentlySelectedDate : dates[dates.length - 1]; 223 | if (compareVersions(version, '0.4') >= 0 && dates.length > 1) { 224 | sliderContainer.style.display = 'block'; 225 | sliderContainer.innerHTML = ` 226 | 227 | 228 | 229 |

Date:

230 | `; 231 | const datalist = document.querySelector('#markers'); 232 | 233 | for (let i = 0; i < dates.length; i++) { 234 | datalist.innerHTML += ` 235 | 236 | `; 237 | } 238 | 239 | const value = document.querySelector('#slider-value'); 240 | const input = document.querySelector('#slider-input'); 241 | input.value = currentlySelectedDate ? dates.indexOf(currentlySelectedDate) : dates.length - 1; 242 | value.textContent = dates[input.value]; 243 | input.addEventListener('input', (event) => { 244 | value.textContent = dates[event.target.value]; 245 | selectedDate = dates[event.target.value]; 246 | }); 247 | return selectedDate; 248 | } else if (compareVersions(version, '0.4') <= 0 && dates.length > 1) { 249 | sliderContainer.style.display = 'block'; 250 | sliderContainer.innerHTML = ` 251 | 252 |

Date: ${dates[dates.length - 1]}

253 | `; 254 | const input = document.querySelector('#slider-input'); 255 | input.value = 1; 256 | } else { 257 | sliderContainer.style.display = 'none'; 258 | } 259 | }; 260 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | .label-text { 2 | height: auto; 3 | width: auto; 4 | } 5 | 6 | .node div { 7 | white-space: normal; 8 | text-align: center; 9 | } 10 | 11 | .centred-label { 12 | /* max-width: 100px; */ 13 | text-align: center; 14 | overflow-wrap: normal; 15 | } 16 | 17 | .wrap-text { 18 | text-align: center; 19 | overflow-wrap: normal; 20 | text-align: center; 21 | } 22 | 23 | .node-image { 24 | width: 100%; 25 | height: 100px; 26 | } 27 | 28 | .image-holder { 29 | position: relative; 30 | margin: 0 auto; 31 | width: 160px; 32 | } 33 | 34 | .node-glyph { 35 | position: absolute; 36 | width: 30px; 37 | height: 30px; 38 | } 39 | 40 | .node-label { 41 | background-color: white; 42 | display: inline; 43 | } 44 | 45 | .node-flag { 46 | border: 1px solid #b2b3b9; 47 | height: 100%; 48 | right: 0; 49 | position: absolute; 50 | } 51 | 52 | .top-left { 53 | top: 0px; 54 | left: 0px; 55 | } 56 | 57 | .top-right { 58 | top: 0px; 59 | right: 0px; 60 | } 61 | 62 | .bottom-left { 63 | bottom: 0px; 64 | left: 0px; 65 | } 66 | 67 | .bottom-right { 68 | bottom: 0px; 69 | right: 0px; 70 | } 71 | -------------------------------------------------------------------------------- /src/utils/bezierBuilder.js: -------------------------------------------------------------------------------- 1 | // This function takes a curve 2 | // (defined by the return object from https://pomax.github.io/bezierjs) 3 | // and extracts the associated points. 4 | // It then builds an SVG object from those points. 5 | export default (offsetCurve) => { 6 | const { curves } = offsetCurve; 7 | // Extract the points 8 | const curvesPoints = curves.map((curve) => curve.points); 9 | // Define the starting point (M) 10 | const m = `M${curvesPoints[0][0].x},${curvesPoints[0][0].y} `; 11 | // Get just the C points 12 | const cPoints = curvesPoints.map((curve) => curve.filter((array, index) => index !== 0)); 13 | // Build the C object based on the points 14 | const c = cPoints.reduce((total, pointSet) => { 15 | return `${total}${pointSet.reduce((cTot, points) => { 16 | return `${cTot}${points.x},${points.y} `; 17 | }, 'C')}`; 18 | }, ''); 19 | // Combine the starting point and curve definitions 20 | return m + c; 21 | }; 22 | -------------------------------------------------------------------------------- /src/utils/bods.js: -------------------------------------------------------------------------------- 1 | import { compareVersions } from 'compare-versions'; 2 | 3 | export const getDates = (statements) => { 4 | const uniqueDates = new Set(); 5 | const validStatements = Array.isArray(statements) ? statements : [statements]; 6 | 7 | for (const statement of validStatements) { 8 | if (!uniqueDates.has(statement.statementDate)) { 9 | uniqueDates.add(statement.statementDate); 10 | } 11 | } 12 | 13 | const arr = Array.from(uniqueDates); 14 | return arr.sort((a, b) => { 15 | return new Date(a) - new Date(b); 16 | }); 17 | }; 18 | 19 | export const filteredData = (statements, selectedDate, version) => { 20 | const validStatements = Array.isArray(statements) ? statements : [statements]; 21 | 22 | // Ensure all statements include recordType as the minimum valid criteria 23 | const filteredStatements = validStatements.filter((statement) => { 24 | return statement.recordType || statement.statementType; 25 | }); 26 | 27 | if (compareVersions(version, '0.4') >= 0) { 28 | // get all statements with matching recordId and recordType values 29 | const recordIdCount = {}; 30 | 31 | for (const statement of filteredStatements) { 32 | const key = `${statement.recordId || ''}-${statement.recordType}`; 33 | recordIdCount[key] = (recordIdCount[key] || 0) + 1; 34 | } 35 | 36 | const duplicateStatements = filteredStatements.filter((statement) => { 37 | const key = `${statement.recordId || ''}-${statement.recordType}`; 38 | return recordIdCount[key] > 1; 39 | }); 40 | 41 | const uniqueStatements = filteredStatements.filter((statement) => { 42 | const key = `${statement.recordId || ''}-${statement.recordType}`; 43 | return recordIdCount[key] === 1; 44 | }); 45 | 46 | // remove all statements outside of selectedDate 47 | const filteredByDate = (array) => { 48 | return array.filter((statement) => { 49 | return !statement.statementDate || statement.statementDate <= selectedDate; 50 | }); 51 | }; 52 | 53 | // remove all statements but most recent of those already filtered by date 54 | const filteredByRecency = (array) => { 55 | return Object.values( 56 | array.reduce((acc, statement) => { 57 | const key = `${statement.recordId || ''}-${statement.recordType}`; 58 | if (!acc[key] || new Date(statement.statementDate) > new Date(acc[key].statementDate)) { 59 | acc[key] = statement; 60 | } 61 | return acc; 62 | }, {}) 63 | ); 64 | }; 65 | 66 | // remove closed records of various types 67 | const filterByRecordStatus = (array) => { 68 | return array.filter((statement) => { 69 | if (statement.recordStatus === 'closed') { 70 | return false; 71 | } 72 | 73 | if ( 74 | statement.recordType === 'entity' && 75 | statement.dissolutionDate && 76 | new Date(statement.dissolutionDate) <= new Date(selectedDate) 77 | ) { 78 | return false; 79 | } 80 | 81 | if ( 82 | statement.recordType === 'person' && 83 | statement.deathDate && 84 | new Date(statement.deathDate) <= new Date(selectedDate) 85 | ) { 86 | return false; 87 | } 88 | 89 | if (statement.recordType === 'relationship' && statement.recordDetails?.interests) { 90 | statement.recordDetails.interests = statement.recordDetails.interests.filter((interest) => { 91 | return !interest.endDate || new Date(interest.endDate) > new Date(selectedDate); 92 | }); 93 | 94 | if (statement.recordDetails.interests.length === 0) { 95 | return false; 96 | } 97 | } 98 | 99 | return true; 100 | }); 101 | }; 102 | 103 | // filter original statements to only show selected statements 104 | const selectStatements = (array) => { 105 | return filteredStatements.filter((statement) => 106 | array.some( 107 | (filtered) => 108 | filtered.recordId === statement.recordId && 109 | filtered.recordType === statement.recordType && 110 | filtered.statementDate === statement.statementDate 111 | ) 112 | ); 113 | }; 114 | 115 | const duplicateSelectedStatements = selectStatements( 116 | filterByRecordStatus(filteredByRecency(filteredByDate(duplicateStatements))) 117 | ); 118 | const uniqueSelectedStatements = selectStatements( 119 | filterByRecordStatus(filteredByRecency(filteredByDate(uniqueStatements))) 120 | ); 121 | 122 | return [...duplicateSelectedStatements, ...uniqueSelectedStatements]; 123 | } else { 124 | // get all statements with statementID values in replacesStatements array 125 | const nodeTypes = ['ownershipOrControlStatement', 'entityStatement', 'personStatement']; 126 | const replacedStatements = new Set(); 127 | 128 | // filter statements by <0.4 changes over time, and only return the latest state of data 129 | filteredStatements.forEach((statement) => { 130 | if (nodeTypes.includes(statement.statementType)) { 131 | (statement.replacesStatements || []).forEach((id) => replacedStatements.add(id)); 132 | } 133 | }); 134 | 135 | return filteredStatements.filter((statement) => { 136 | return !(replacedStatements.has(statement.statementID) && nodeTypes.includes(statement.statementType)); 137 | }); 138 | } 139 | }; 140 | -------------------------------------------------------------------------------- /src/utils/curve.js: -------------------------------------------------------------------------------- 1 | function sign(x) { 2 | return x < 0 ? -1 : 1; 3 | } 4 | 5 | // Calculate the slopes of the tangents (Hermite-type interpolation) based on 6 | // the following paper: Steffen, M. 1990. A Simple Method for Monotonic 7 | // Interpolation in One Dimension. Astronomy and Astrophysics, Vol. 239, NO. 8 | // NOV(II), P. 443, 1990. 9 | function slope3(that, x2, y2) { 10 | var h0 = that._x1 - that._x0, 11 | h1 = x2 - that._x1, 12 | s0 = (that._y1 - that._y0) / (h0 || (h1 < 0 && -0)), 13 | s1 = (y2 - that._y1) / (h1 || (h0 < 0 && -0)), 14 | p = (s0 * h1 + s1 * h0) / (h0 + h1); 15 | return (sign(s0) + sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), 0.5 * Math.abs(p)) || 0; 16 | } 17 | 18 | // Calculate a one-sided slope. 19 | function slope2(that, t) { 20 | var h = that._x1 - that._x0; 21 | return h ? ((3 * (that._y1 - that._y0)) / h - t) / 2 : t; 22 | } 23 | 24 | // According to https://en.wikipedia.org/wiki/Cubic_Hermite_spline#Representations 25 | // "you can express cubic Hermite interpolation in terms of cubic Bézier curves 26 | // with respect to the four values p0, p0 + m0 / 3, p1 - m1 / 3, p1". 27 | function point(that, t0, t1) { 28 | var x0 = that._x0, 29 | y0 = that._y0, 30 | x1 = that._x1, 31 | y1 = that._y1, 32 | dx = (x1 - x0) / 3; 33 | that._context.bezierCurveTo(x0 + dx * 0.9, y0 + dx * t0 * 0.9, x1 - dx * 0.9, y1 - dx * t1 * 0.9, x1, y1); 34 | } 35 | 36 | function MonotoneX(context) { 37 | this._context = context; 38 | } 39 | 40 | MonotoneX.prototype = { 41 | areaStart: function () { 42 | this._line = 0; 43 | }, 44 | areaEnd: function () { 45 | this._line = NaN; 46 | }, 47 | lineStart: function () { 48 | this._x0 = this._x1 = this._y0 = this._y1 = this._t0 = NaN; 49 | this._point = 0; 50 | }, 51 | lineEnd: function () { 52 | switch (this._point) { 53 | case 2: 54 | this._context.lineTo(this._x1, this._y1); 55 | break; 56 | case 3: 57 | point(this, this._t0, slope2(this, this._t0)); 58 | break; 59 | } 60 | if (this._line || (this._line !== 0 && this._point === 1)) this._context.closePath(); 61 | this._line = 1 - this._line; 62 | }, 63 | point: function (x, y) { 64 | var t1 = NaN; 65 | 66 | (x = +x), (y = +y); 67 | if (x === this._x1 && y === this._y1) return; // Ignore coincident points. 68 | switch (this._point) { 69 | case 0: 70 | this._point = 1; 71 | this._line ? this._context.lineTo(x, y) : this._context.moveTo(x, y); 72 | break; 73 | case 1: 74 | this._point = 2; 75 | break; 76 | case 2: 77 | this._point = 3; 78 | point(this, slope2(this, (t1 = slope3(this, x, y))), t1); 79 | break; 80 | default: 81 | point(this, this._t0, (t1 = slope3(this, x, y))); 82 | break; 83 | } 84 | 85 | (this._x0 = this._x1), (this._x1 = x); 86 | (this._y0 = this._y1), (this._y1 = y); 87 | this._t0 = t1; 88 | }, 89 | }; 90 | 91 | function MonotoneY(context) { 92 | this._context = new ReflectContext(context); 93 | } 94 | 95 | (MonotoneY.prototype = Object.create(MonotoneX.prototype)).point = function (x, y) { 96 | MonotoneX.prototype.point.call(this, y, x); 97 | }; 98 | 99 | function ReflectContext(context) { 100 | this._context = context; 101 | } 102 | 103 | ReflectContext.prototype = { 104 | moveTo: function (x, y) { 105 | this._context.moveTo(y, x); 106 | }, 107 | closePath: function () { 108 | this._context.closePath(); 109 | }, 110 | lineTo: function (x, y) { 111 | this._context.lineTo(y, x); 112 | }, 113 | bezierCurveTo: function (x1, y1, x2, y2, x, y) { 114 | this._context.bezierCurveTo(y1, x1, y2, x2, y, x); 115 | }, 116 | }; 117 | 118 | export function monotoneX(context) { 119 | return new MonotoneX(context); 120 | } 121 | 122 | export function monotoneY(context) { 123 | return new MonotoneY(context); 124 | } 125 | -------------------------------------------------------------------------------- /src/utils/sanitiser.js: -------------------------------------------------------------------------------- 1 | export default (string) => { 2 | // Convert non-string values to strings before sanitising 3 | const coercedString = string.toString(); 4 | const map = { 5 | '&': '&', 6 | '<': '<', 7 | '>': '>', 8 | '"': '"', 9 | "'": ''', 10 | '/': '/', 11 | }; 12 | const reg = /[&<>"'/]/gi; 13 | return coercedString.replace(reg, (match) => map[match]); 14 | }; 15 | -------------------------------------------------------------------------------- /src/utils/svgTools.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3'; 2 | 3 | const clearSVG = async (container) => { 4 | d3.select(container).select('#bods-svg').remove(); 5 | d3.select(container).append('svg').attr('id', 'bods-svg'); 6 | }; 7 | 8 | export { clearSVG }; 9 | -------------------------------------------------------------------------------- /src/utils/svgsaver.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import _computedStyles from 'computed-styles'; 4 | import _fileSaver from 'file-saver'; 5 | 6 | var _createClass = (function () { 7 | function defineProperties(target, props) { 8 | for (var i = 0; i < props.length; i++) { 9 | var descriptor = props[i]; 10 | descriptor.enumerable = descriptor.enumerable || false; 11 | descriptor.configurable = true; 12 | if ('value' in descriptor) descriptor.writable = true; 13 | Object.defineProperty(target, descriptor.key, descriptor); 14 | } 15 | } 16 | return function (Constructor, protoProps, staticProps) { 17 | if (protoProps) defineProperties(Constructor.prototype, protoProps); 18 | if (staticProps) defineProperties(Constructor, staticProps); 19 | return Constructor; 20 | }; 21 | })(); 22 | 23 | function _interopRequireDefault(obj) { 24 | return obj && obj.__esModule ? obj : { default: obj }; 25 | } 26 | 27 | function _classCallCheck(instance, Constructor) { 28 | if (!(instance instanceof Constructor)) { 29 | throw new TypeError('Cannot call a class as a function'); 30 | } 31 | } 32 | 33 | var _computedStyles2 = _interopRequireDefault(_computedStyles); 34 | 35 | var _fileSaver2 = _interopRequireDefault(_fileSaver); 36 | 37 | var svgStyles = { 38 | // Whitelist of CSS styles and default values 39 | 'alignment-baseline': 'auto', 40 | 'baseline-shift': 'baseline', 41 | clip: 'auto', 42 | 'clip-path': 'none', 43 | 'clip-rule': 'nonzero', 44 | color: 'rgb(51, 51, 51)', 45 | 'color-interpolation': 'srgb', 46 | 'color-interpolation-filters': 'linearrgb', 47 | 'color-profile': 'auto', 48 | 'color-rendering': 'auto', 49 | cursor: 'auto', 50 | direction: 'ltr', 51 | display: 'inline', 52 | 'dominant-baseline': 'auto', 53 | 'enable-background': '', 54 | fill: 'rgb(0, 0, 0)', 55 | 'fill-opacity': '1', 56 | 'fill-rule': 'nonzero', 57 | filter: 'none', 58 | 'flood-color': 'rgb(0, 0, 0)', 59 | 'flood-opacity': '1', 60 | font: '', 61 | 'font-family': 'normal', 62 | 'font-size': 'medium', 63 | 'font-size-adjust': 'auto', 64 | 'font-stretch': 'normal', 65 | 'font-style': 'normal', 66 | 'font-variant': 'normal', 67 | 'font-weight': '400', 68 | 'glyph-orientation-horizontal': '0deg', 69 | 'glyph-orientation-vertical': 'auto', 70 | 'image-rendering': 'auto', 71 | kerning: 'auto', 72 | 'letter-spacing': '0', 73 | 'lighting-color': 'rgb(255, 255, 255)', 74 | marker: '', 75 | 'marker-end': 'none', 76 | 'marker-mid': 'none', 77 | 'marker-start': 'none', 78 | mask: 'none', 79 | opacity: '1', 80 | overflow: 'visible', 81 | 'paint-order': 'fill', 82 | 'pointer-events': 'auto', 83 | 'shape-rendering': 'auto', 84 | 'stop-color': 'rgb(0, 0, 0)', 85 | 'stop-opacity': '1', 86 | stroke: 'none', 87 | 'stroke-dasharray': 'none', 88 | 'stroke-dashoffset': '0', 89 | 'stroke-linecap': 'butt', 90 | 'stroke-linejoin': 'miter', 91 | 'stroke-miterlimit': '4', 92 | 'stroke-opacity': '1', 93 | 'stroke-width': '1', 94 | 'text-anchor': 'start', 95 | 'text-decoration': 'none', 96 | 'text-rendering': 'auto', 97 | 'unicode-bidi': 'normal', 98 | visibility: 'visible', 99 | 'word-spacing': '0px', 100 | 'writing-mode': 'lr-tb', 101 | }; 102 | 103 | var svgAttrs = [ 104 | // white list of attributes 105 | 'id', 106 | 'xml: base', 107 | 'xml: lang', 108 | 'xml: space', // Core 109 | 'height', 110 | 'result', 111 | 'width', 112 | 'x', 113 | 'y', // Primitive 114 | 'xlink: href', // Xlink attribute 115 | 'href', 116 | 'style', 117 | 'class', 118 | 'd', 119 | 'pathLength', // Path 120 | 'x', 121 | 'y', 122 | 'dx', 123 | 'dy', 124 | 'glyphRef', 125 | 'format', 126 | 'x1', 127 | 'y1', 128 | 'x2', 129 | 'y2', 130 | 'rotate', 131 | 'textLength', 132 | 'startOffset', 133 | 'cx', 134 | 'cy', 135 | 'r', 136 | 'rx', 137 | 'ry', 138 | 'fx', 139 | 'fy', 140 | 'width', 141 | 'height', 142 | 'refX', 143 | 'refY', 144 | 'orient', 145 | 'markerUnits', 146 | 'markerWidth', 147 | 'markerHeight', 148 | 'maskUnits', 149 | 'transform', 150 | 'viewBox', 151 | 'version', // Container 152 | 'preserveAspectRatio', 153 | 'xmlns', 154 | 'points', // Polygons 155 | 'offset', 156 | 'xlink:href', 157 | ]; 158 | 159 | // http://www.w3.org/TR/SVG/propidx.html 160 | // via https://github.com/svg/svgo/blob/master/plugins/_collections.js 161 | var inheritableAttrs = [ 162 | 'clip-rule', 163 | 'color', 164 | 'color-interpolation', 165 | 'color-interpolation-filters', 166 | 'color-profile', 167 | 'color-rendering', 168 | 'cursor', 169 | 'direction', 170 | 'fill', 171 | 'fill-opacity', 172 | 'fill-rule', 173 | 'font', 174 | 'font-family', 175 | 'font-size', 176 | 'font-size-adjust', 177 | 'font-stretch', 178 | 'font-style', 179 | 'font-variant', 180 | 'font-weight', 181 | 'glyph-orientation-horizontal', 182 | 'glyph-orientation-vertical', 183 | 'image-rendering', 184 | 'kerning', 185 | 'letter-spacing', 186 | 'marker', 187 | 'marker-end', 188 | 'marker-mid', 189 | 'marker-start', 190 | 'pointer-events', 191 | 'shape-rendering', 192 | 'stroke', 193 | 'stroke-dasharray', 194 | 'stroke-dashoffset', 195 | 'stroke-linecap', 196 | 'stroke-linejoin', 197 | 'stroke-miterlimit', 198 | 'stroke-opacity', 199 | 'stroke-width', 200 | 'text-anchor', 201 | 'text-rendering', 202 | 'transform', 203 | 'visibility', 204 | 'white-space', 205 | 'word-spacing', 206 | 'writing-mode', 207 | ]; 208 | 209 | /* Some simple utilities */ 210 | 211 | var isFunction = function isFunction(a) { 212 | return typeof a === 'function'; 213 | }; 214 | var isDefined = function isDefined(a) { 215 | return typeof a !== 'undefined'; 216 | }; 217 | var isUndefined = function isUndefined(a) { 218 | return typeof a === 'undefined'; 219 | }; 220 | var isObject = function isObject(a) { 221 | return a !== null && typeof a === 'object'; 222 | }; 223 | 224 | // from https://github.com/npm-dom/is-dom/blob/master/index.js 225 | function isNode(val) { 226 | if (!isObject(val)) { 227 | return false; 228 | } 229 | if (isDefined(window) && isObject(window.Node)) { 230 | return val instanceof window.Node; 231 | } 232 | return typeof val.nodeType === 'number' && typeof val.nodeName === 'string'; 233 | } 234 | 235 | /* Some utilities for cloning SVGs with inline styles */ 236 | // Removes attributes that are not valid for SVGs 237 | function cleanAttrs(el, attrs, styles) { 238 | // attrs === false - remove all, attrs === true - allow all 239 | if (attrs === true) { 240 | return; 241 | } 242 | 243 | Array.prototype.slice.call(el.attributes).forEach(function (attr) { 244 | // remove if it is not style nor on attrs whitelist 245 | // keeping attributes that are also styles because attributes override 246 | if (attr.specified) { 247 | if ( 248 | attrs === '' || 249 | attrs === false || 250 | (isUndefined(styles[attr.name]) && attrs.indexOf(attr.name) < 0) 251 | ) { 252 | el.removeAttribute(attr.name); 253 | } 254 | } 255 | }); 256 | } 257 | 258 | function cleanStyle(tgt, parentStyles) { 259 | parentStyles = parentStyles || tgt.parentNode.style; 260 | inheritableAttrs.forEach(function (key) { 261 | if (tgt.style[key] === parentStyles[key]) { 262 | tgt.style.removeProperty(key); 263 | } 264 | }); 265 | } 266 | 267 | function domWalk(src, tgt, down, up) { 268 | down(src, tgt); 269 | var children = src.childNodes; 270 | for (var i = 0; i < children.length; i++) { 271 | domWalk(children[i], tgt.childNodes[i], down, up); 272 | } 273 | up(src, tgt); 274 | } 275 | 276 | // Clones an SVGElement, copies approprate atttributes and styles. 277 | function cloneSvg(src, attrs, styles) { 278 | var clonedSvg = src.cloneNode(true); 279 | 280 | domWalk( 281 | src, 282 | clonedSvg, 283 | function (src, tgt) { 284 | if (tgt.style) { 285 | (0, _computedStyles2['default'])(src, tgt.style, styles); 286 | } 287 | }, 288 | function (src, tgt) { 289 | if (tgt.style && tgt.parentNode) { 290 | cleanStyle(tgt); 291 | } 292 | if (tgt.attributes) { 293 | cleanAttrs(tgt, attrs, styles); 294 | } 295 | } 296 | ); 297 | 298 | return clonedSvg; 299 | } 300 | 301 | /* global Image, MouseEvent */ 302 | 303 | /* Some simple utilities for saving SVGs, including an alternative to saveAs */ 304 | 305 | // detection 306 | var DownloadAttributeSupport = 307 | typeof document !== 'undefined' && 308 | 'download' in document.createElement('a') && 309 | typeof MouseEvent === 'function'; 310 | 311 | function saveUri(uri, name) { 312 | if (DownloadAttributeSupport) { 313 | var dl = document.createElement('a'); 314 | dl.setAttribute('href', uri); 315 | dl.setAttribute('download', name); 316 | // firefox doesn't support `.click()`... 317 | // from https://github.com/sindresorhus/multi-download/blob/gh-pages/index.js 318 | dl.dispatchEvent(new MouseEvent('click')); 319 | return true; 320 | } else if (typeof window !== 'undefined') { 321 | window.open(uri, '_blank', ''); 322 | return true; 323 | } 324 | 325 | return false; 326 | } 327 | 328 | function createCanvas(uri, name, cb) { 329 | var canvas = document.createElement('canvas'); 330 | var context = canvas.getContext('2d'); 331 | 332 | var image = new Image(); 333 | image.onload = function () { 334 | canvas.width = image.width; 335 | canvas.height = image.height; 336 | context.drawImage(image, 0, 0); 337 | 338 | cb(canvas); 339 | }; 340 | image.src = uri; 341 | return true; 342 | } 343 | 344 | function savePng(uri, name) { 345 | return createCanvas(uri, name, function (canvas) { 346 | if (isDefined(canvas.toBlob)) { 347 | canvas.toBlob(function (blob) { 348 | _fileSaver2['default'].saveAs(blob, name); 349 | }); 350 | } else { 351 | saveUri(canvas.toDataURL('image/png'), name); 352 | } 353 | }); 354 | } 355 | 356 | /* global Blob */ 357 | 358 | var isIE11 = !!window.MSInputMethodContext && !!document.documentMode; 359 | 360 | // inheritable styles may be overridden by parent, always copy for now 361 | inheritableAttrs.forEach(function (k) { 362 | if (k in svgStyles) { 363 | svgStyles[k] = true; 364 | } 365 | }); 366 | 367 | var SvgSaver = (function () { 368 | _createClass(SvgSaver, null, [ 369 | { 370 | key: 'getSvg', 371 | value: function getSvg(el) { 372 | if (isUndefined(el) || el === '') { 373 | el = document.body.querySelector('svg'); 374 | } else if (typeof el === 'string') { 375 | el = document.body.querySelector(el); 376 | } 377 | if (el && el.tagName !== 'svg') { 378 | el = el.querySelector('svg'); 379 | } 380 | if (!isNode(el)) { 381 | throw new Error("svgsaver: Can't find an svg element"); 382 | } 383 | return el; 384 | }, 385 | }, 386 | { 387 | key: 'getFilename', 388 | value: function getFilename(el, filename, ext) { 389 | if (!filename || filename === '') { 390 | filename = (el.getAttribute('title') || 'untitled') + '.' + ext; 391 | } 392 | return encodeURI(filename); 393 | }, 394 | 395 | /** 396 | * SvgSaver constructor. 397 | * @constructs SvgSaver 398 | * @api public 399 | * 400 | * @example 401 | * var svgsaver = new SvgSaver(); // creates a new instance 402 | * var svg = document.querySelector('#mysvg'); // find the SVG element 403 | * svgsaver.asSvg(svg); // save as SVG 404 | */ 405 | }, 406 | ]); 407 | 408 | function SvgSaver() { 409 | var _ref = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; 410 | 411 | var attrs = _ref.attrs; 412 | var styles = _ref.styles; 413 | 414 | _classCallCheck(this, SvgSaver); 415 | 416 | this.attrs = attrs === undefined ? svgAttrs : attrs; 417 | this.styles = styles === undefined ? svgStyles : styles; 418 | } 419 | 420 | /** 421 | * Return the cloned SVG after cleaning 422 | * 423 | * @param {SVGElement} el The element to copy. 424 | * @returns {SVGElement} SVG text after cleaning 425 | * @api public 426 | */ 427 | 428 | _createClass(SvgSaver, [ 429 | { 430 | key: 'cloneSVG', 431 | value: function cloneSVG(el) { 432 | el = SvgSaver.getSvg(el); 433 | var svg = cloneSvg(el, this.attrs, this.styles); 434 | 435 | svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); 436 | svg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); 437 | svg.setAttribute('version', 1.1); 438 | 439 | // height and width needed to download in FireFox 440 | svg.setAttribute('width', svg.getAttribute('width') || '500'); 441 | svg.setAttribute('height', svg.getAttribute('height') || '900'); 442 | 443 | return svg; 444 | }, 445 | 446 | /** 447 | * Return the SVG HTML text after cleaning 448 | * 449 | * @param {SVGElement} el The element to copy. 450 | * @returns {String} SVG text after cleaning 451 | * @api public 452 | */ 453 | }, 454 | { 455 | key: 'getHTML', 456 | value: function getHTML(el) { 457 | var svg = this.cloneSVG(el); 458 | 459 | var html = svg.outerHTML; 460 | if (html) { 461 | return html; 462 | } 463 | 464 | // see http://stackoverflow.com/questions/19610089/unwanted-namespaces-on-svg-markup-when-using-xmlserializer-in-javascript-with-ie 465 | svg.removeAttribute('xmlns'); 466 | svg.removeAttribute('xmlns:xlink'); 467 | 468 | svg.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns', 'http://www.w3.org/2000/svg'); 469 | svg.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xlink', 'http://www.w3.org/1999/xlink'); 470 | 471 | return new window.XMLSerializer().serializeToString(svg); 472 | }, 473 | 474 | /** 475 | * Return the SVG, after cleaning, as a text/xml Blob 476 | * 477 | * @param {SVGElement} el The element to copy. 478 | * @returns {Blog} SVG as a text/xml Blob 479 | * @api public 480 | */ 481 | }, 482 | { 483 | key: 'getBlob', 484 | value: function getBlob(el) { 485 | var html = this.getHTML(el); 486 | return new Blob([html], { type: 'text/xml' }); 487 | }, 488 | 489 | /** 490 | * Return the SVG, after cleaning, as a image/svg+xml;base64 URI encoded string 491 | * 492 | * @param {SVGElement} el The element to copy. 493 | * @returns {String} SVG as image/svg+xml;base64 URI encoded string 494 | * @api public 495 | */ 496 | }, 497 | { 498 | key: 'getUri', 499 | value: function getUri(el) { 500 | var html = encodeURIComponent(this.getHTML(el)); 501 | if (isDefined(window.btoa)) { 502 | // see http://stackoverflow.com/questions/23223718/failed-to-execute-btoa-on-window-the-string-to-be-encoded-contains-characte 503 | return 'data:image/svg+xml;base64,' + window.btoa(unescape(html)); 504 | } 505 | return 'data:image/svg+xml,' + html; 506 | }, 507 | 508 | /** 509 | * Saves the SVG as a SVG file using method compatible with the browser 510 | * 511 | * @param {SVGElement} el The element to copy. 512 | * @param {string} [filename] The filename to save, defaults to the SVG title or 'untitled.svg' 513 | * @returns {SvgSaver} The SvgSaver instance 514 | * @api public 515 | */ 516 | }, 517 | { 518 | key: 'asSvg', 519 | value: function asSvg(el, filename) { 520 | el = SvgSaver.getSvg(el); 521 | filename = SvgSaver.getFilename(el, filename, 'svg'); 522 | if (isFunction(Blob)) { 523 | return _fileSaver2['default'].saveAs(this.getBlob(el), filename); 524 | } 525 | return saveUri(this.getUri(el), filename); 526 | }, 527 | 528 | /** 529 | * Gets the SVG as a PNG data URI. 530 | * 531 | * @param {SVGElement} el The element to copy. 532 | * @param {Function} cb Call back called with the PNG data uri. 533 | * @api public 534 | */ 535 | }, 536 | { 537 | key: 'getPngUri', 538 | value: function getPngUri(el, cb) { 539 | if (isIE11) { 540 | console.error('svgsaver: getPngUri not supported on IE11'); 541 | } 542 | el = SvgSaver.getSvg(el); 543 | var filename = SvgSaver.getFilename(el, null, 'png'); 544 | return createCanvas(this.getUri(el), filename, function (canvas) { 545 | cb(canvas.toDataURL('image/png')); 546 | }); 547 | }, 548 | 549 | /** 550 | * Saves the SVG as a PNG file using method compatible with the browser 551 | * 552 | * @param {SVGElement} el The element to copy. 553 | * @param {string} [filename] The filename to save, defaults to the SVG title or 'untitled.png' 554 | * @returns {SvgSaver} The SvgSaver instance 555 | * @api public 556 | */ 557 | }, 558 | { 559 | key: 'asPng', 560 | value: function asPng(el, filename) { 561 | if (isIE11) { 562 | console.error('svgsaver: asPng not supported on IE11'); 563 | } 564 | el = SvgSaver.getSvg(el); 565 | filename = SvgSaver.getFilename(el, filename, 'png'); 566 | return savePng(this.getUri(el), filename); 567 | }, 568 | }, 569 | ]); 570 | 571 | return SvgSaver; 572 | })(); 573 | 574 | export { SvgSaver }; 575 | -------------------------------------------------------------------------------- /tests/__mocks__/dataMock.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "statementId": "1dc0e987-5c57-4a1c-b3ad-61353b66a9b7", 4 | "declarationSubject": "c359f58d2977", 5 | "statementDate": "2020-03-04", 6 | "publicationDetails": { 7 | "publicationDate": "2020-03-04", 8 | "bodsVersion": "0.4", 9 | "publisher": { 10 | "name": "Profitech Ltd" 11 | } 12 | }, 13 | "recordId": "c359f58d2977", 14 | "recordStatus": "new", 15 | "recordType": "entity", 16 | "recordDetails": { 17 | "isComponent": false, 18 | "entityType": { 19 | "type": "registeredEntity" 20 | }, 21 | "name": "Profitech Ltd", 22 | "foundingDate": "2019-09-03", 23 | "identifiers": [ 24 | { 25 | "scheme": "GB-COH", 26 | "id": "2063384560" 27 | } 28 | ] 29 | } 30 | }, 31 | { 32 | "statementId": "019a93f1-e470-42e9-957b-03559861b2e2", 33 | "declarationSubject": "c359f58d2977", 34 | "statementDate": "2020-03-04", 35 | "publicationDetails": { 36 | "publicationDate": "2020-03-04", 37 | "bodsVersion": "0.4", 38 | "publisher": { 39 | "name": "Profitech Ltd" 40 | } 41 | }, 42 | "recordId": "10478c6cf6de", 43 | "recordStatus": "new", 44 | "recordType": "person", 45 | "recordDetails": { 46 | "isComponent": false, 47 | "personType": "knownPerson", 48 | "nationalities": [ 49 | { 50 | "code": "GB", 51 | "name": "United Kingdom of Great Britain and Northern Ireland (the)" 52 | } 53 | ], 54 | "names": [ 55 | { 56 | "type": "legal", 57 | "fullName": "Jennifer Hewitson-Smith", 58 | "givenName": "Jennifer", 59 | "familyName": "Hewitson-Smith" 60 | }, 61 | { 62 | "type": "alternative", 63 | "fullName": "Jenny Hewitson-Smith" 64 | } 65 | ], 66 | "birthDate": "1978-07", 67 | "addresses": [ 68 | { 69 | "type": "service", 70 | "address": "76 York Road Bournemouth", 71 | "postCode": "BH81 3LO", 72 | "country": { 73 | "name": "United Kingdom", 74 | "code": "GB" 75 | } 76 | } 77 | ] 78 | } 79 | }, 80 | { 81 | "statementId": "fbfd0547-d0c6-4a00-b559-5c5e91c34f5c", 82 | "declarationSubject": "c359f58d2977", 83 | "statementDate": "2020-03-04", 84 | "publicationDetails": { 85 | "publicationDate": "2020-03-04", 86 | "bodsVersion": "0.4", 87 | "publisher": { 88 | "name": "Profitech Ltd" 89 | } 90 | }, 91 | "recordId": "93b53022ae6a", 92 | "recordStatus": "new", 93 | "recordType": "relationship", 94 | "recordDetails": { 95 | "isComponent": false, 96 | "subject": "c359f58d2977", 97 | "interestedParty": "10478c6cf6de", 98 | "interests": [ 99 | { 100 | "type": "shareholding", 101 | "beneficialOwnershipOrControl": true, 102 | "directOrIndirect": "direct", 103 | "startDate": "2016-04-06", 104 | "share": { 105 | "exact": 100 106 | } 107 | } 108 | ] 109 | } 110 | } 111 | ] 112 | -------------------------------------------------------------------------------- /tests/__mocks__/edgeMock.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | id: 'fbfd0547-d0c6-4a00-b559-5c5e91c34f5c', 4 | statementDate: '2020-03-04', 5 | recordId: '93b53022ae6a', 6 | recordStatus: 'new', 7 | interests: [ 8 | { 9 | type: 'shareholding', 10 | share: { 11 | exact: 100, 12 | }, 13 | category: 'ownership', 14 | }, 15 | ], 16 | interestRelationship: 'direct', 17 | controlStroke: 5, 18 | controlText: 'Controls', 19 | shareText: 'Owns 100%', 20 | shareStroke: 10, 21 | unknownStroke: 5, 22 | source: '10478c6cf6de', 23 | target: 'c359f58d2977', 24 | config: { 25 | style: 'fill: none; stroke: #000; stroke-width: 5px;', 26 | share: { 27 | arrowheadShape: 'Full', 28 | strokeValue: '#652eb1', 29 | positiveStroke: 10, 30 | }, 31 | }, 32 | replaces: [], 33 | fullDescription: { 34 | statementId: 'fbfd0547-d0c6-4a00-b559-5c5e91c34f5c', 35 | declarationSubject: 'c359f58d2977', 36 | statementDate: '2020-03-04', 37 | publicationDetails: { 38 | publicationDate: '2020-03-04', 39 | bodsVersion: '0.4', 40 | publisher: { 41 | name: 'Profitech Ltd', 42 | }, 43 | }, 44 | recordId: '93b53022ae6a', 45 | recordStatus: 'new', 46 | recordType: 'relationship', 47 | recordDetails: { 48 | isComponent: false, 49 | subject: 'c359f58d2977', 50 | interestedParty: '10478c6cf6de', 51 | interests: [ 52 | { 53 | type: 'shareholding', 54 | beneficialOwnershipOrControl: true, 55 | directOrIndirect: 'direct', 56 | startDate: '2016-04-06', 57 | share: { 58 | exact: 100, 59 | }, 60 | }, 61 | ], 62 | }, 63 | }, 64 | description: { 65 | statementDate: '2020-03-04', 66 | recordId: '93b53022ae6a', 67 | interests: [ 68 | { 69 | type: 'shareholding', 70 | beneficialOwnershipOrControl: true, 71 | directOrIndirect: 'direct', 72 | startDate: '2016-04-06', 73 | share: { 74 | exact: 100, 75 | }, 76 | }, 77 | ], 78 | }, 79 | }, 80 | ]; 81 | -------------------------------------------------------------------------------- /tests/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /tests/bods.test.js: -------------------------------------------------------------------------------- 1 | import { filteredData } from '../src/utils/bods.js'; 2 | 3 | import testData from './__mocks__/dataMock.json'; 4 | 5 | describe('filteredData()', () => { 6 | it('should not throw an error when called', () => { 7 | const data = testData; 8 | const version = '0.4'; 9 | const result = () => filteredData(data, '2020-03-04', version); 10 | expect(result).not.toThrow(); 11 | }); 12 | 13 | it('should return data in the correct format', () => { 14 | const data = testData; 15 | const version = '0.4'; 16 | const keys = [ 17 | 'declarationSubject', 18 | 'publicationDetails', 19 | 'recordDetails', 20 | 'recordId', 21 | 'recordStatus', 22 | 'recordType', 23 | 'statementDate', 24 | 'statementId', 25 | ]; 26 | const result = filteredData(data, '2020-03-04', version); 27 | expect(result[0]).toContainKeys(keys); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/edges.test.js: -------------------------------------------------------------------------------- 1 | import { checkInterests, getOwnershipEdges, getEdges } from '../src/model/edges/edges.js'; 2 | 3 | import testData from './__mocks__/dataMock.json'; 4 | 5 | describe('checkInterests()', () => { 6 | it('should not throw an error when called', () => { 7 | const relationship = ''; 8 | const result = () => checkInterests(relationship); 9 | expect(result).not.toThrow(); 10 | }); 11 | }); 12 | 13 | describe('getOwnershipEdges()', () => { 14 | it('should not throw an error when called', () => { 15 | const data = testData; 16 | const result = () => getOwnershipEdges(data); 17 | expect(result).not.toThrow(); 18 | }); 19 | }); 20 | 21 | describe('getEdges()', () => { 22 | it('should not throw an error when called', () => { 23 | const data = testData; 24 | const result = () => getEdges(data); 25 | expect(result).not.toThrow(); 26 | }); 27 | 28 | it('should contain an "edges" property', () => { 29 | const data = testData; 30 | const edges = getEdges(data); 31 | expect(edges.edges).toBeDefined(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/html.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import fs from 'fs'; 6 | import path from 'path'; 7 | 8 | const html = fs.readFileSync(path.resolve(__dirname, '../demo/index.html'), 'utf8'); 9 | 10 | describe('Initial HTML', () => { 11 | beforeEach(() => { 12 | document.documentElement.innerHTML = html.toString(); 13 | }); 14 | 15 | it('should contain an svg element with id of "bods-svg"', () => { 16 | const id = 'bods-svg'; 17 | const element = 'svg'; 18 | 19 | const result = document.querySelector(`#${id}`); 20 | 21 | expect(result.id).toEqual(id); 22 | expect(result.nodeName).toEqual(element); 23 | }); 24 | 25 | it('should contain an input field of type "file"', () => { 26 | const type = 'file'; 27 | const element = 'input'; 28 | 29 | const result = document.querySelector(`${element}[type="${type}"]`); 30 | 31 | expect(result).not.toBeNull(); 32 | }); 33 | 34 | it('should contain a button with id of "import"', () => { 35 | const id = 'import'; 36 | const element = 'button'; 37 | 38 | const result = document.querySelector(`${element}[id="${id}"]`); 39 | 40 | expect(result).not.toBeNull(); 41 | }); 42 | 43 | it('should contain a textarea field with id of "result"', () => { 44 | const id = 'result'; 45 | const element = 'textarea'; 46 | 47 | const result = document.querySelector(`${element}[id="${id}"]`); 48 | 49 | expect(result).not.toBeNull(); 50 | }); 51 | 52 | it('should contain a button with id of "draw-vis"', () => { 53 | const id = 'draw-vis'; 54 | const element = 'button'; 55 | 56 | const result = document.querySelector(`${element}[id="${id}"]`); 57 | 58 | expect(result).not.toBeNull(); 59 | }); 60 | 61 | it('should contain a button with id of "svg-clear"', () => { 62 | const id = 'svg-clear'; 63 | const element = 'button'; 64 | 65 | const result = document.querySelector(`${element}[id="${id}"]`); 66 | 67 | expect(result).not.toBeNull(); 68 | }); 69 | 70 | it('should contain a button with id of "zoom_in"', () => { 71 | const id = 'zoom_in'; 72 | const element = 'button'; 73 | 74 | const result = document.querySelector(`${element}[id="${id}"]`); 75 | 76 | expect(result).not.toBeNull(); 77 | }); 78 | 79 | it('should contain a button with id of "zoom_out"', () => { 80 | const id = 'zoom_out'; 81 | const element = 'button'; 82 | 83 | const result = document.querySelector(`${element}[id="${id}"]`); 84 | 85 | expect(result).not.toBeNull(); 86 | }); 87 | 88 | it('should contain a button with id of "download-svg"', () => { 89 | const id = 'download-svg'; 90 | const element = 'button'; 91 | 92 | const result = document.querySelector(`${element}[id="${id}"]`); 93 | 94 | expect(result).not.toBeNull(); 95 | }); 96 | 97 | it('should contain a button with id of "download-png"', () => { 98 | const id = 'download-png'; 99 | const element = 'button'; 100 | 101 | const result = document.querySelector(`${element}[id="${id}"]`); 102 | 103 | expect(result).not.toBeNull(); 104 | }); 105 | 106 | it('should contain an empty div with id of "disclosure-widget"', () => { 107 | const id = 'disclosure-widget'; 108 | const element = 'div'; 109 | 110 | const result = document.querySelector(`${element}[id="${id}"]`); 111 | 112 | expect(result).not.toBeNull(); 113 | expect(result.innerHTML).toEqual(''); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /tests/nodes.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { getPersonNodes, getEntityNodes, findMatchingStatement, getNodes } from '../src/model/nodes/nodes.js'; 6 | 7 | import testEdges from './__mocks__/edgeMock.js'; 8 | import testData from './__mocks__/dataMock.json'; 9 | 10 | describe('getPersonNodes()', () => { 11 | it('should not throw an error when called', () => { 12 | const data = testData; 13 | const result = () => getPersonNodes(data); 14 | expect(result).not.toThrow(); 15 | }); 16 | 17 | it('should return data in the correct format', () => { 18 | const data = testData; 19 | const keys = [ 20 | 'class', 21 | 'config', 22 | 'countryCode', 23 | 'description', 24 | 'fullDescription', 25 | 'id', 26 | 'label', 27 | 'labelType', 28 | 'nodeType', 29 | 'recordId', 30 | 'replaces', 31 | 'statementDate', 32 | ]; 33 | const result = getPersonNodes(data); 34 | expect(result[0]).toContainKeys(keys); 35 | }); 36 | }); 37 | 38 | describe('getEntityNodes()', () => { 39 | it('should not throw an error when called', () => { 40 | const data = testData; 41 | const result = () => getEntityNodes(data); 42 | expect(result).not.toThrow(); 43 | }); 44 | 45 | it('should return data in the correct format', () => { 46 | const data = testData; 47 | const keys = [ 48 | 'class', 49 | 'config', 50 | 'countryCode', 51 | 'description', 52 | 'fullDescription', 53 | 'id', 54 | 'label', 55 | 'labelType', 56 | 'nodeType', 57 | 'recordId', 58 | 'replaces', 59 | 'statementDate', 60 | ]; 61 | const result = getPersonNodes(data); 62 | expect(result[0]).toContainKeys(keys); 63 | }); 64 | }); 65 | 66 | describe('findMatchingStatement()', () => { 67 | it('should not throw an error when called', () => { 68 | const data = testData; 69 | const id = '10478c6cf6de'; 70 | const result = () => findMatchingStatement(data, id); 71 | expect(result).not.toThrow(); 72 | }); 73 | 74 | it('should return a matching statement given an id', () => { 75 | const data = [{ recordId: '123' }, { recordId: '456' }]; 76 | const id = '123'; 77 | const result = findMatchingStatement(data, id); 78 | expect(result).toEqual(data[0]); 79 | }); 80 | }); 81 | 82 | describe('getNodes()', () => { 83 | it('should not throw an error when called', () => { 84 | const data = testData; 85 | const edges = testEdges; 86 | const result = () => getNodes(data, edges); 87 | expect(result).not.toThrow(); 88 | }); 89 | 90 | it('should contain a "nodes" property', () => { 91 | const data = testData; 92 | const edges = testEdges; 93 | const nodes = getNodes(data, edges); 94 | expect(nodes.nodes).toBeDefined(); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /tests/parse.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { parse } from '../src/parse/parse.js'; 6 | 7 | describe('parse()', () => { 8 | it('should return stringified, formatted JSON', () => { 9 | const data = `[{"test": "test"}]`; 10 | const parsedData = JSON.parse(data); 11 | const formattedData = JSON.stringify(parsedData, null, 2); 12 | const result = parse(data); 13 | expect(result.formatted).toEqual(formattedData); 14 | }); 15 | 16 | it('should return parsed JSON', () => { 17 | const data = `[{"test": "test"}]`; 18 | const parsedData = JSON.parse(data); 19 | const result = parse(data); 20 | expect(result.parsed).toEqual(parsedData); 21 | }); 22 | 23 | it('should throw an error if the input data is not valid JSON', () => { 24 | const data = '@'; 25 | const result = () => JSON.parse(data); 26 | expect(result).toThrow(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /tests/renderD3.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | 7 | import { setupD3 } from '../src/render/renderD3.js'; 8 | 9 | const html = fs.readFileSync(path.resolve(__dirname, '../demo/index.html'), 'utf8'); 10 | 11 | describe('setupD3()', () => { 12 | beforeEach(() => { 13 | document.documentElement.innerHTML = html.toString(); 14 | }); 15 | 16 | it('should return an svg selection', () => { 17 | const id = 'svg-holder'; 18 | 19 | const element = document.querySelector(`#${id}`); 20 | const { svg } = setupD3(element); 21 | 22 | expect(svg.node().nodeName).toEqual('svg'); 23 | }); 24 | 25 | it('should return an svg group selection', () => { 26 | const id = 'svg-holder'; 27 | 28 | const element = document.querySelector(`#${id}`); 29 | const { inner } = setupD3(element); 30 | 31 | expect(inner.node().nodeName).toEqual('g'); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/sanitiser.test.js: -------------------------------------------------------------------------------- 1 | import sanitiser from '../src/utils/sanitiser.js'; 2 | 3 | describe('Sanitiser', () => { 4 | it('should escape dangerous characters from strings', () => { 5 | const dangerousString = `&<>"'/`; 6 | const escapedString = '&<>"'/'; 7 | const result = sanitiser(dangerousString); 8 | expect(result).toEqual(escapedString); 9 | }); 10 | 11 | it('should not escape alphanumeric values from strings', () => { 12 | const testString = `abc123`; 13 | const result = sanitiser(testString); 14 | expect(result).toEqual(testString); 15 | }); 16 | 17 | it('should coerce non-string values to strings', () => { 18 | const testValue = 123; 19 | const testValueToString = '123'; 20 | const result = sanitiser(testValue); 21 | expect(result).toEqual(testValueToString); 22 | }); 23 | 24 | it('should return an error if not provided with any value', () => { 25 | const resultFunction = () => { 26 | sanitiser(); 27 | }; 28 | expect(resultFunction).toThrow(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /webpack.demo.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'url'; 2 | import path, { dirname } from 'path'; 3 | import { CleanWebpackPlugin } from 'clean-webpack-plugin'; 4 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 5 | import TerserPlugin from 'terser-webpack-plugin'; 6 | import CopyPlugin from 'copy-webpack-plugin'; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = dirname(__filename); 10 | 11 | export default { 12 | mode: 'production', 13 | entry: { 14 | main: './demo/index.js', 15 | 'bods-dagre': './src/index.js', 16 | }, 17 | output: { 18 | filename: '[name].js', 19 | path: path.resolve(__dirname, 'demo-build'), 20 | library: 'BODSDagre', 21 | }, 22 | devtool: 'source-map', 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.css$/, 27 | use: ['style-loader', 'css-loader'], 28 | }, 29 | { 30 | test: /\.js$/, 31 | exclude: /node_modules/, 32 | loader: 'babel-loader', 33 | resolve: { 34 | fullySpecified: false, 35 | }, 36 | }, 37 | ], 38 | }, 39 | plugins: [ 40 | // new CleanWebpackPlugin(), 41 | new HtmlWebpackPlugin({ 42 | inject: true, 43 | template: './demo/index.html', 44 | excludeChunks: ['bods-dagre'], 45 | }), 46 | new TerserPlugin({ 47 | // Use multi-process parallel running to improve the build speed 48 | // Default number of concurrent runs: os.cpus().length - 1 49 | parallel: true, 50 | terserOptions: { 51 | // Enable file caching 52 | nameCache: {}, 53 | sourceMap: true, 54 | }, 55 | }), 56 | new CopyPlugin({ 57 | patterns: [ 58 | { from: 'src/images', to: 'images' }, 59 | { from: 'node_modules/flag-icons/flags/4x3/', to: 'images/flags/' }, 60 | { from: 'demo/script-tag.html', to: 'script-tag.html' }, 61 | { from: 'demo/demo.css', to: 'demo.css' }, 62 | { from: 'demo/script-tag.js', to: 'demo.js' }, 63 | ], 64 | }), 65 | ], 66 | }; 67 | -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'url'; 2 | import path, { dirname } from 'path'; 3 | import { CleanWebpackPlugin } from 'clean-webpack-plugin'; 4 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 5 | import TerserPlugin from 'terser-webpack-plugin'; 6 | import CopyPlugin from 'copy-webpack-plugin'; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = dirname(__filename); 10 | 11 | export default { 12 | mode: 'development', 13 | entry: './demo/index.js', 14 | watch: true, 15 | output: { 16 | filename: 'main.js', 17 | path: path.resolve(__dirname, 'dev-build'), 18 | }, 19 | devServer: { 20 | static: path.join(__dirname, 'dev-build'), 21 | compress: true, 22 | port: 9000, 23 | }, 24 | devtool: 'source-map', 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.css$/, 29 | use: ['style-loader', 'css-loader'], 30 | }, 31 | { 32 | test: /\.js$/, 33 | exclude: /node_modules/, 34 | loader: 'babel-loader', 35 | resolve: { 36 | fullySpecified: false, 37 | }, 38 | }, 39 | ], 40 | }, 41 | plugins: [ 42 | new CleanWebpackPlugin(), 43 | new HtmlWebpackPlugin({ inject: true, template: './demo/index.html' }), 44 | new TerserPlugin({ 45 | // Use multi-process parallel running to improve the build speed 46 | // Default number of concurrent runs: os.cpus().length - 1 47 | parallel: true, 48 | terserOptions: { 49 | sourceMap: true, 50 | }, 51 | }), 52 | new CopyPlugin({ 53 | patterns: [ 54 | { from: 'src/images', to: 'images' }, 55 | { from: 'node_modules/flag-icons/flags/4x3/', to: 'images/flags/' }, 56 | ], 57 | }), 58 | ], 59 | }; 60 | -------------------------------------------------------------------------------- /webpack.library.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'url'; 2 | import path, { dirname } from 'path'; 3 | import { CleanWebpackPlugin } from 'clean-webpack-plugin'; 4 | import TerserPlugin from 'terser-webpack-plugin'; 5 | import CopyPlugin from 'copy-webpack-plugin'; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = dirname(__filename); 9 | 10 | export default { 11 | mode: 'production', 12 | entry: './src/index.js', 13 | output: { 14 | filename: 'bods-dagre.js', 15 | path: path.resolve(__dirname, 'dist'), 16 | library: 'BODSDagre', 17 | }, 18 | devtool: 'source-map', 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.css$/, 23 | use: ['style-loader', 'css-loader'], 24 | }, 25 | { 26 | test: /\.js$/, 27 | exclude: /node_modules/, 28 | loader: 'babel-loader', 29 | resolve: { 30 | fullySpecified: false, 31 | }, 32 | }, 33 | ], 34 | }, 35 | plugins: [ 36 | new CleanWebpackPlugin(), 37 | new TerserPlugin({ 38 | // Use multi-process parallel running to improve the build speed 39 | // Default number of concurrent runs: os.cpus().length - 1 40 | parallel: true, 41 | // Enable file caching 42 | terserOptions: { 43 | nameCache: {}, 44 | }, 45 | }), 46 | new CopyPlugin({ 47 | patterns: [ 48 | { from: 'src/images', to: 'images' }, 49 | { from: 'node_modules/flag-icons/flags/4x3/', to: 'images/flags/' }, 50 | ], 51 | }), 52 | ], 53 | }; 54 | --------------------------------------------------------------------------------