BODS Data Visualisation Demo
10 |12 | 13 | 14 |
├── .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 |${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 = `
${fullDescription}
${fullDescription}
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:
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 | --------------------------------------------------------------------------------